Tech Blog

エンドユーザー向けDVDレンタルアプリを作っている話 — dvdrental 管理アプリと対になる Vue 3 + Spring Boot 構成と記事マップ

Vue3 Spring Boot TypeScript PostgreSQL

はじめに

以前、PostgreSQL のサンプル DB dvdrental をベースにした管理画面アプリを作りました。

PostgreSQL のサンプル DB dvdrental をベースに Spring Boot + Thymeleaf で DVD レンタル管理アプリを作った話

管理アプリは「スタッフが使う業務システム」です。顧客管理、在庫管理、レンタル管理、支払い管理、売上レポートを一通り備えています。

ただ、これだけでは「管理する側の道具」しかありません。

実際に DVD を借りる側のアプリがない。

この記事はその続きです。管理アプリと対になるエンドユーザー向けアプリ — つまり「顧客が自分で作品を探し、オンラインでレンタルを申し込めるアプリ」を作っている話です。

このアプリは現在も開発中です。 この記事は完成報告ではなく、進行中の設計判断や技術選定の記録として書いています。 機能の追加・変更に合わせて随時更新していく予定です。


アプリの位置づけ

管理アプリとエンドユーザーアプリは、同じ dvdrental データベースを共有しながら、完全に別のアプリケーションとして作っています。

                      ┌─────────────────────┐
                      │    PostgreSQL       │
                      │    (dvdrental)      │
                      └──────────┬──────────┘
                                 │ 共通DB
              ┌──────────────────┴───────────────────┐
              │                                      │
   ┌──────────▼──────────┐              ┌────────────▼────────────┐
   │    管理アプリ        │              │   エンドユーザーアプリ    │
   │  Spring Boot        │              │  Spring Boot (REST API) │
   │  + Thymeleaf        │              │  + Vue 3 + TypeScript   │
   │                     │              │                         │
   │  スタッフ専用        │              │  顧客向け                │
   │  社内ネットワーク     │             │  インターネット公開       │
   └─────────────────────┘              └─────────────────────────┘
アプリ技術スタック対象ユーザー公開範囲
管理アプリSpring Boot + Thymeleafスタッフ社内ネットワーク
エンドユーザーアプリSpring Boot (REST API) + Vue 3 + TypeScript顧客インターネット公開

両アプリは同じ dvdrental PostgreSQL データベースを共有し、それぞれ独立したアプリケーションとして動作します。

管理アプリは「業務を回す側」、エンドユーザーアプリは「利用する側」。 役割が違うので、技術スタックも認証も分けています。

2 つのアプリが共有する技術と、この記事シリーズの読み方

管理アプリエンドユーザーアプリ
言語JavaJava
バックエンドフレームワークSpring BootSpring Boot
フロントエンドThymeleaf(サーバーサイドレンダリング)+ TypeScriptVue 3 + TypeScript
API 形式MVC(Controller → View)REST API(Controller → JSON)

バックエンドは両アプリとも Java + Spring Boot が共通です。フロントエンドのアーキテクチャが分かれています。管理アプリは Thymeleaf でサーバーサイドレンダリング、エンドユーザーアプリは Vue 3 による SPA です。どちらも TypeScript を使っており、型安全な UI 実装という点では同じ方針です。

この 2 本柱の構成を作ったことで、Spring Boot / Java / Vue 3 / TypeScript の実装パターンを両面から記録できるようになりました。この記事シリーズは「作った記録」であると同時に、これら 4 つの技術について後で読み返す参考書・教科書として機能するよう書いています。各記事はテーマを絞っているので、特定の技術や実装パターンを調べたいときに単独で参照できます。

管理アプリとの大きな違い:ログイン不要で使える公開エリアがある

管理アプリはすべての機能がログイン必須です。スタッフしかアクセスできない前提で設計されており、未ログイン状態で到達できる画面は存在しません。

一方、エンドユーザーアプリにはログインしなくても使える公開エリアがあります。

管理アプリエンドユーザーアプリ
未ログインで使える機能なし(全画面ログイン必須)作品一覧・詳細・在庫確認・おすすめ表示
ログイン後に使える機能顧客管理・在庫管理・売上レポートなどマイページ・レンタル申込・決済・履歴確認
対象ユーザー社内スタッフ一般ユーザー(会員登録前も含む)

「まず作品を見てから、借りたくなったら会員登録する」という導線を成立させるために、この公開エリアは必須の設計でした。検索・閲覧・在庫確認までは認証なしで完結できます。

なぜ同じリポジトリに入れず分けたのか、その判断の詳細は別記事に書いています。 → 管理画面と顧客向けアプリを「別リポジトリ」に分けた理由と、その判断で得たもの・失ったもの


技術スタックの選択

技術
バックエンドSpring Boot 3 + MyBatis + Spring Security
フロントエンドVue 3 + TypeScript + Vite
データベースPostgreSQL(dvdrental ベース + Flyway で拡張)
決済PayPay Dynamic QR Code API / PAY.JP
デプロイ構成mvn package 一発でフロントを jar に同梱

管理アプリが Spring Boot + Thymeleaf だったのに対して、このアプリでは Spring Boot を REST API に徹させ、UI は Vue 3 で構築する分離構成を選んでいます。なぜそうしたかは次の記事で詳しく書いています。

Spring Boot バックエンド + Vue 3 フロントエンドの分離構成を選んだ理由と、実際の繋ぎ方


画面構成

画面遷移の全体は単一の App.vuecurrentPage ステートで管理しています。
Vue Router は使わず、コンポーネントの出し分けで画面切り替えを実現しています。

著作権への配慮について
UI の構成・レイアウトは、EC サイトや動画配信サービスといった実際の大手サービスの画面を参考にしながら、既存サービスのデザインに依存しない独自実装としています。タイトル画像・プレビュー動画も同様の方針で、既存作品・既存キャラクターを使わず AI 生成素材を採用しています。

① 共通ヘッダー・ナビゲーション

作品一覧画面

ヘッダーは 3 層構造(トップバー・マストヘッド・グローバルタブ)で構成されています。決済画面に入ると通常ヘッダーが非表示になり、シンプルな「Isolated Checkout」ヘッダーに切り替わります。

<!-- App.vue(抜粋) -->
<header class="masthead" v-if="!isCheckoutFlow">
  <!-- 通常ヘッダー -->
</header>
<header class="checkout-masthead" v-if="isCheckoutFlow">
  <div class="brand">CINEMA DAYS</div>
  <div class="secure-note">🔒 安全な通信で決済処理を行っています</div>
</header>

決済画面を「Isolated Checkout」にすべき理由と実装

currentPage の型は union literal で定義しており、Vue Router は使っていません。タブ遷移の際にルーティング設計をしなくて済む分、小さなアプリでは状態遷移がシンプルになります。

// App.vue(currentPage 型定義 抜粋)
type PageType =
  | 'films' | 'cd-rentals' | 'streaming'
  | 'ranking' | 'feature' | 'detail'
  | 'auth' | 'member-register' | 'member-register-confirm'
  | 'mypage' | 'checkout'

const currentPage = ref<PageType>('films')

App.vue のページ状態管理を、単純な currentPage で回した設計判断


② 作品一覧画面(FilmsView.vue)

作品一覧画面

作品フィルタは Vue 3 の computed だけで実装しており、API の追加呼び出しは発生しません。
サイドバーの開閉状態は localStorage で保持しています。

<!-- FilmsView.vue(フィルタ部分 抜粋) -->
<ul class="filter-list">
  <li
    v-for="cat in categoryOptions"
    :key="cat"
    :class="{ 'filter-active': selectedCategory === cat }"
    @click="applyCategoryFilter(cat)"
  >{{ cat }}</li>
</ul>

Vue 3 computed だけでフロント完結フィルタ検索を実装した話
localStorage でサイドバーの開閉状態を保持しつつ、初回描画のちらつきを防ぐ実装


③ 作品詳細画面(FilmDetailView.vue)

作品詳細画面

タイトル画像・プレビュー動画は Gemini Ultra で生成

作品詳細に表示しているタイトル画像(ポスタービジュアル)とプレビュー動画は、Google Gemini Ultra の画像生成・動画生成機能で作りました。

  • 著作権に配慮して、既存作品・既存キャラクターに依存しない条件で生成
  • 生成プロンプトを細かく固定せず、いわゆる「お任せ」に近い形で実行
  • 結果として、整いすぎない・どこか独特な素材が揃い、UI としての存在感が強くなった

開発中の画面映えと記事の読み物としての面白さを両立させる手段として、AI 生成素材はかなり実用的でした。

ただし、画像・動画が欠けるケースを前提にフォールバック構成も入れています。

// FilmDetailView.vue(タイトル画像フォールバック 抜粋)
const buildTitleImageCandidates = (filmId: number): string[] => [
  `/media/film-${filmId}-title.jpg`,
  `/media/film-${filmId}-title.png`,
]

候補を順に試し、すべて失敗した場合はプレースホルダ表示に落とす構成です。

Gemini Ultra で映画ポスターとプレビュー動画を生成してDVDレンタルアプリに使った話

作品詳細には「今すぐレンタル」と「カートへ追加」の 2 つのボタンがあります。この 2 つを分ける理由は UX 設計上の判断です。

<!-- FilmDetailView.vue(CTA ボタン 抜粋) -->
<template v-if="isRentalSection">
  <button class="btn-primary" @click="emit('direct-checkout', film)">
    今すぐレンタル(決済)
  </button>
  <button v-if="isAuthenticated" class="btn-secondary"
          @click="emit('add-to-cart', film)">
    カートへ追加
  </button>
</template>

EC サイトで「カートに入れる」と「今すぐ購入」を分けるべき理由と実装

詳細画面への遷移時は、前画面のスクロール位置が残らないよう resetDetailViewport で先頭への強制スクロールとフォーカス解除を行っています。film.filmId の watch と onMounted の両方で呼ぶことで、初回マウント時と同一コンポーネントが再利用されるケースの両方をカバーしています。

// FilmDetailView.vue(スクロール・フォーカスリセット 抜粋)
const resetDetailViewport = () => {
  window.scrollTo({ top: 0, left: 0, behavior: 'auto' })
  const activeElement = document.activeElement
  if (activeElement instanceof HTMLElement) {
    activeElement.blur()
  }
}

watch(() => film.filmId, () => {
  resetDetailViewport()
  void resolveInitialMedia()
})

onMounted(() => {
  resetDetailViewport()
  void resolveInitialMedia()
})

詳細遷移時に scrollTo(0, 0) と blur() を入れた理由


④ ログイン・会員登録画面(AuthView.vue)

ログイン・会員登録画面

ログイン処理はフロント → バックエンド API という通常の REST 呼び出しです。
Spring Security のフィルタチェーンは使わず、BCryptPasswordEncoder で手動照合します。

// AuthController.java(ログイン処理 抜粋)
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
    AuthMapper.AuthRecord record = authMapper.findByEmail(request.email());

    if (record == null) {
        // メールアドレスが存在しない場合も同じメッセージを返す(列挙防止)
        return ResponseEntity.status(401)
                .body(new LoginResponse(false, "メールアドレスまたはパスワードが正しくありません。", ""));
    }

    if (!passwordEncoder.matches(request.password(), record.passwordHash())) {
        return ResponseEntity.status(401)
                .body(new LoginResponse(false, "メールアドレスまたはパスワードが正しくありません。", ""));
    }

    authMapper.updateLastLoginAt(record.customerId());
    return ResponseEntity.ok(new LoginResponse(true, "ログインに成功しました。", "/films"));
}

⑤ 会員登録フォーム(MemberRegistrationView.vue)

会員情報登録フォーム

「入力 → 確認 → 登録完了」の 3 ステップ構成です。確認画面では入力値を読み取り専用で表示し、戻って修正できます。

ステップ管理の仕組み

画面遷移は Vue Router を使わず、App.vuecurrentPage ref で管理しています。
フォームの入力値は registerDraft に一時保存し、確認画面にそのまま props で渡します。

// App.vue(会員登録フロー 抜粋)
const registerDraft = ref<RegisterRequest | null>(null)

const openMemberRegisterConfirm = (payload: RegisterRequest) => {
  registerDraft.value = payload           // 入力値を一時保存
  currentPage.value = 'member-register-confirm'
}
<!-- App.vue(テンプレート 抜粋) -->
<MemberRegistrationView
  v-else-if="currentPage === 'member-register'"
  @confirm="openMemberRegisterConfirm"
  @back-auth="currentPage = 'auth'"
/>
<MemberRegistrationConfirmView
  v-else-if="currentPage === 'member-register-confirm' && registerDraft"
  :payload="registerDraft"
  @back-edit="currentPage = 'member-register'"
  @registered="currentPage = 'auth'"
/>

MemberRegistrationView は入力値を emit('confirm', { ...form }) で親に渡すだけで、「どこへ遷移するか」は自分では知りません。画面遷移の制御を親コンポーネントに集約するパターンです。

入力バリデーション

パスワードの検証は @submit.prevent="goConfirm" 内で行います。HTML の required に頼らず、パスワード一致・桁数・英数字混在を TypeScript で明示的にチェックします。

// MemberRegistrationView.vue(バリデーション 抜粋)
const goConfirm = () => {
  passwordError.value = ''
  if (form.password !== form.passwordConfirmation) {
    passwordError.value = 'パスワードが一致しません。'
    return
  }
  if (form.password.length < 8) {
    passwordError.value = 'パスワードは8文字以上で入力してください。'
    return
  }
  if (!/[a-zA-Z]/.test(form.password) || !/[0-9]/.test(form.password)) {
    passwordError.value = 'パスワードには英字と数字を含めてください。'
    return
  }
  emit('confirm', { ...form })
}

確認画面から API 呼び出し

確認画面(MemberRegistrationConfirmView.vue)が「登録する」ボタンを持ち、ここで API を呼びます。
loading フラグで二重送信を防ぎ、エラーは画面内に表示します。

// MemberRegistrationConfirmView.vue(登録処理 抜粋)
const submit = async () => {
  loading.value = true
  try {
    const result = await registerMember(props.payload)
    message.value = result.message
    emit('registered')           // 親が 'auth' ページへ切り替える
  } catch (error) {
    isError.value = true
    message.value = error instanceof Error ? error.message : '登録に失敗しました。'
  } finally {
    loading.value = false
  }
}

⑥ カートドロワー(CartDrawer.vue)

右端からスライドインするドロワー UI です。作品一覧・詳細画面でカートアイコンをクリックすると開きます。

カートドロワー

カート状態は localStorage に永続化しており、ページを閉じても内容が保持されます。

// useCart.ts(カート追加 抜粋)
function addToCart(film: PublicFilmSummary): boolean {
  ensureInitialized()
  const exists = itemsRef.value.some((item) => item.filmId === film.filmId)
  if (exists) return false  // 重複追加を防ぐ

  const next: CartItem = { ...film, addedAt: new Date().toISOString() }
  itemsRef.value = [next, ...itemsRef.value]
  saveToStorage(itemsRef.value)
  return true
}

⑦ マイページ(MyPageView.vue)

マイページ

マイページのデータは dvdrental 既存テーブルを JOIN して取得します。レンタルステータスは SQL の CASE 式で動的に計算します。

// MyPageMapper.java(レンタル履歴 抜粋)
@Select("""
        SELECT
            r.rental_id,
            f.title AS film_title,
            CASE
                WHEN r.return_date IS NOT NULL THEN 'RETURNED'
                WHEN r.rental_date < CURRENT_TIMESTAMP - INTERVAL '7 days' THEN 'OVERDUE'
                ELSE 'OPEN'
            END AS rental_status,
            COALESCE(SUM(p.amount), 0) AS billed_amount
        FROM rental r
        JOIN inventory i ON i.inventory_id = r.inventory_id
        JOIN film f ON f.film_id = i.film_id
        LEFT JOIN payment p ON p.rental_id = r.rental_id
        WHERE r.customer_id = #{customerId}
        GROUP BY r.rental_id, r.rental_date, r.return_date, f.title
        ORDER BY r.rental_date DESC
        LIMIT #{size} OFFSET #{offset}
        """)
List<RentalHistoryItemResponse> selectRentalHistory(
        @Param("customerId") int customerId,
        @Param("size") int size,
        @Param("offset") int offset);

⑧ 決済画面(CheckoutView.vue)

決済画面は通常ヘッダー・ナビを非表示にした「Isolated Checkout」構成です。

決済画面

PAY.JP と PayPay の 2 つの決済方法を実装しています。

PayPay の場合は Dynamic QR Code を生成し、PayPay アプリに飛ばします。
決済完了はポーリングで検知します。

// PayPayService.java(QR コード生成 抜粋)
public QRCodeDetails createQRCode(int amount, String merchantPaymentId, String redirectUrl)
        throws ApiException {

    QRCode qrCode = new QRCode();
    qrCode.setMerchantPaymentId(merchantPaymentId);
    qrCode.setAmount(new MoneyAmount().amount(amount).currency(MoneyAmount.CurrencyEnum.JPY));
    qrCode.setCodeType("ORDER_QR");
    qrCode.setOrderDescription("DVD レンタル決済");
    qrCode.setRequestedAt(Instant.now().getEpochSecond());

    return paymentApi.createQRCode(qrCode);
}

PAY.JP の場合は Payjp.js をフロント側でマウントし、カード情報は自サーバーを一切経由しません(PCI DSS 準拠)。


dvdrental への DB カスタマイズ

dvdrental はサンプル DB なので、エンドユーザーアプリを動かすために必要なテーブルを追加しています。
スキーマ変更は Flyway で管理しています。

Flyway でスキーマ migration を段階的に育てていく運用方法

追加テーブル ① customer_authentication

dvdrental の customer テーブルは業務データ(氏名・住所など)しか持っておらず、認証に必要なパスワードハッシュがありません。そこで認証専用テーブルを別に作りました。

CREATE TABLE public.customer_authentication (
    customer_id   INTEGER      PRIMARY KEY
                               REFERENCES public.customer(customer_id),
    email         VARCHAR(255) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    auth_status   VARCHAR(20)  NOT NULL DEFAULT 'ACTIVE',
    last_login_at TIMESTAMPTZ,
    created_at    TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    updated_at    TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

MyBatis の Mapper はこのテーブルを直接参照します。

// AuthMapper.java(抜粋)
@Select("""
        SELECT customer_id, email, password_hash, auth_status
        FROM public.customer_authentication
        WHERE LOWER(email) = LOWER(#{email})
        LIMIT 1
        """)
AuthRecord findByEmail(@Param("email") String email);

追加テーブル ② rental_tier

dvdrental の film テーブルにある rental_rate はフラットな数値です。
「新作」「準新作」「スタンダード」のような区分で絞り込めるようにするため、区分マスタを追加しました。

CREATE TABLE public.rental_tier (
    rental_tier_id SERIAL       PRIMARY KEY,
    code           VARCHAR(20)  NOT NULL UNIQUE,   -- 'NEW', 'SEMI_NEW', 'STANDARD'
    label          VARCHAR(50)  NOT NULL,
    default_rate   NUMERIC(5,2) NOT NULL
);

-- film テーブルへ FK カラムを追加
ALTER TABLE public.film
    ADD COLUMN rental_tier_id INTEGER REFERENCES public.rental_tier(rental_tier_id);

作品一覧 API ではこの rental_tier と JOIN して tier_code を返し、フロント側のフィルタに使います。

// PublicFilmMapper.java(抜粋)
@Select("""
        SELECT
            f.film_id, f.title, f.release_year,
            c.name AS category_name,
            COALESCE(f.rental_rate, rt.default_rate) AS rental_rate,
            rt.code AS tier_code
        FROM film f
        JOIN film_category fc ON fc.film_id = f.film_id
        JOIN category c ON c.category_id = fc.category_id
        JOIN rental_tier rt ON rt.rental_tier_id = f.rental_tier_id
        ORDER BY f.film_id
        LIMIT #{size} OFFSET #{offset}
        """)
List<PublicFilmSummaryResponse> selectPublicFilms(@Param("size") int size, @Param("offset") int offset);

追加カラム ③ film.taste_tags

LLM(Ollama / OpenAI)で映画の雰囲気タグを自動生成し、film テーブルの taste_tags カラムに保存しています。

ALTER TABLE film ADD COLUMN IF NOT EXISTS taste_tags TEXT[];

Spring Boot バッチが映画タイトルと説明文を LLM に渡し、返ってきたタグ配列を TEXT[] で保存します。
PostgreSQL の配列型と Spring Boot のマッピング、バッチ処理の実装は別記事で書いています。

LLM(Ollama/OpenAI)で映画タグを自動生成するバッチを Spring Boot に組み込んだ話
PostgreSQL の配列型(text[])を Spring Boot + JPA でマッピングする方法


バックエンドの API 構成

REST API は公開エンドポイントと認証必須エンドポイントを分けています。

Public API(認証不要)
  GET  /api/public/health       ヘルスチェック
  GET  /api/public/films        作品一覧(ページング)

Authenticated API(ログイン済み)
  POST /api/auth/login          ログイン
  GET  /api/me/profile          自分の会員情報
  GET  /api/me/rentals          自分のレンタル履歴
  GET  /api/me/payments         自分の支払い履歴
  POST /api/payments/paypay     PayPay QR コード生成
  GET  /api/payments/paypay/{id} 決済ステータス確認(ポーリング)
  POST /api/payments/payjp      PAY.JP カード登録・課金

開発時は localhost:5173(Vue)→ localhost:8082(Spring Boot)の別オリジン構成です。
本番では mvn package 一発でフロントのビルド成果物を static/ に同梱し、Spring Boot が配信します。

Vue 3 + Vite + Spring Boot で CORS と Proxy を突破するまでの記録
Spring Boot で Vue3/React のフロントビルドを同梱して自動配信する構成


実装済みの機能

公開エリア(ログイン不要)

  • 作品一覧(カテゴリ・レンタル区分・上映時間フィルタ、フリーワード検索)
  • 作品詳細(タイトル画像・プレビュー動画・在庫状況・出演俳優)
  • 未ログイン向けおすすめ表示
  • LLM 自動生成の雰囲気タグによる絞り込み(実装中)

会員エリア(ログイン後)

  • 会員登録(入力 → 確認 → 完了の 3 ステップ)
  • ログイン・ログアウト
  • マイページ(利用サマリー、レンタル履歴、支払い履歴)
  • 会員情報編集・メールアドレス変更
  • 退会フロー(確認画面あり)
  • ログイン会員向けおすすめ表示・あとで借りるリスト

決済・レンタル

  • 作品をカートに追加する「カートに入れる」フロー(カートドロワー)
  • 即時レンタルに進む「今すぐレンタル」フロー
  • PAY.JP クレジットカード決済(カード登録・使い回し対応)
  • PayPay Dynamic QR Code 決済(ポーリングによる完了検知)

開発中に起きたトラブル

テキスト一括編集によるエンコーディング事故

作業中、テキスト一括置換ツールの書き込み時にエンコーディング起因の文字化けが発生し、複数の Vue ファイルの構文が崩れました。

  • 「部分修正で追う」より「正しい版を全文で再生成する」方が速かった
  • 復旧後に一覧・特集・ランキング・詳細を順に再確認し、最終的に develop へ統合した

文字列置換中心の作業では、エンコーディング事故を前提に復旧手順を決めておくことが重要だと学びました。

一括テキスト編集で起きたエンコーディング事故と、全文再生成で復旧した話


開発中・未着手の機能

この項目は開発進捗に合わせて更新します。

  • customer_authentication テーブルとの会員登録フローの完全結合
  • 在庫確保ロジックの本番対応(現状はサンプルデータを参照するのみ)
  • レンタル期間管理(延長・返却フロー)
  • メールによる申込確認通知
  • AWS へのデプロイ(管理アプリと同様の ECS/Fargate 構成)

記事マップ(このシリーズで書いた記事・書いている記事)

このアプリを作る過程で書いた記事を一覧にします。各記事はテーマを絞っており、Spring Boot / Java / Vue 3 / TypeScript の特定のトピックを調べたいときに単独でも参照できます。
タイトルが末尾に ※作成中 とあるものはドラフトまたは投稿前の状態です。


■ プロジェクト全体の設計判断

テーマ記事
管理アプリと別リポジトリにした理由管理画面と顧客向けアプリを「別リポジトリ」に分けた理由と、その判断で得たもの・失ったもの
プラットフォームとしての将来像DVDレンタルシステムをDMMのようなプラットフォームへ昇華させる構想
ゼロからDBを設計しなかった理由「ゼロからテーブル設計を起こさない」という選択 — dvdrental を土台にした開発

■ バックエンド構成

テーマ記事
Vue 3 + REST API 分離構成を選んだ理由Spring Boot バックエンド + Vue 3 フロントエンドの分離構成を選んだ理由と、実際の繋ぎ方
JPA ではなく MyBatis を選んだ理由Spring Boot API サーバーに JPA ではなく MyBatis を選んだ理由
Flyway で DB スキーマを段階的に育てるFlyway でスキーマ migration を段階的に育てていく運用方法
LLM で映画タグを自動生成するバッチLLM(Ollama/OpenAI)で映画タグを自動生成するバッチを Spring Boot に組み込んだ話
PostgreSQL 配列型と Spring Boot のマッピングPostgreSQL の配列型(text[])を Spring Boot + JPA でマッピングする方法

■ フロントエンド構成

テーマ記事
開発時の CORS / Proxy 問題をどう解決したかVue 3 + Vite + Spring Boot で CORS と Proxy を突破するまでの記録
本番ビルドでフロントを jar に同梱する構成Spring Boot で Vue3/React のフロントビルドを同梱して自動配信する構成
フィルタ・検索を computed だけで実装した話Vue 3 computed だけでフロント完結フィルタ検索を実装した話
localStorage でサイドバー状態を保持しつつちらつきを防ぐlocalStorage でサイドバーの開閉状態を保持しつつ、初回描画のちらつきを防ぐ実装
デスクトップとモバイルを「レスポンシブ CSS で一本化」しない選択デスクトップとモバイルを「レスポンシブ CSS で一本化」しない選択とその確認手順
Gemini Ultra で映画ポスター・動画を生成した話Gemini Ultra で映画ポスターとプレビュー動画を生成してDVDレンタルアプリに使った話
App.vue の currentPage による画面状態管理App.vue のページ状態管理を、単純な currentPage で回した設計判断
詳細遷移時に scrollTo + blur を入れた理由詳細遷移時に scrollTo(0, 0) と blur() を入れた理由
一括テキスト編集で起きたエンコーディング事故と復旧一括テキスト編集で起きたエンコーディング事故と、全文再生成で復旧した話

■ UX 設計・決済フロー

テーマ記事
「カートに入れる」と「今すぐ購入」を分けるべき理由EC サイトで「カートに入れる」と「今すぐ購入」を分けるべき理由と実装
決済画面を「Isolated Checkout」にすべき理由決済画面を「Isolated Checkout」にすべき理由と実装

■ 開発環境

テーマ記事
Windows で Maven / Node を PATH 依存なく動かすWindows で Maven / Node / Java を PATH 依存なく動かすプロジェクト内完結構成

■ 対になる管理アプリの記事

管理アプリ側で書いた記事もこのエンドユーザーアプリと直接関係があるものを掲載します。

テーマ記事
管理アプリの全体構成(dvdrental + Spring Boot + Thymeleaf)PostgreSQL のサンプル DB dvdrental をベースに Spring Boot + Thymeleaf で DVD レンタル管理アプリを作った話
管理アプリを AWS ECS/Fargate + RDS にデプロイした話Spring Boot + Thymeleaf + PostgreSQL の管理画面アプリを AWS ECS/Fargate + RDS にデプロイするときの構成・運用・セキュリティ
dvdrental を日本語化した方法PostgreSQL のサンプル DB dvdrental を日本語化した方法 SQL と CSV を併用して管理画面向けデータを作る
検索条件を画面遷移後も保持する(@SessionAttributes)Spring MVC の @SessionAttributes で検索条件を画面遷移後も保持する
ECS Fargate の固定費(ALB + NAT Gateway)に気づいた話ECS Fargate の固定費(ALB + NAT Gateway)に気づいて構成を見直した話
ECS で動く Spring Boot のログを CloudWatch Logs で確認するAWS ECS で動く Spring Boot アプリのログを CloudWatch Logs で確認する方法
CDK で誤リージョンにデプロイした話と後始末AWS CDK + PowerShell + SSO で誤リージョンにデプロイした話と後始末
Spring Boot の active profile を Docker/ECS で切り替えるSpring Boot の active profile を docker-compose と ECS で切り替える設計
PostgreSQL エンコーディングと Windows の関係PostgreSQL のエンコーディングと Windows の関係

おわりに

管理アプリを作ったとき、「スタッフが顧客データを管理できる場所はできたけど、顧客自身が使える場所がない」という問題がずっと気になっていました。

このエンドユーザーアプリは、その問いに答えるために始めたプロジェクトです。

ただし、まだ完成していません。

決済フローや在庫確保ロジックなど、「本当に動くサービス」として成立させるために必要なパーツはまだ揃っていない部分があります。設計判断や実装の記録を各記事に残しながら、少しずつ完成に近づけています。

この記事シリーズ全体は、Spring Boot / Java / Vue 3 / TypeScript の実装パターンを実際のアプリ開発を通じてまとめた参考書・教科書として機能するよう書いています。特定の技術やトピックを調べたいときは、記事マップから該当カテゴリを直接参照してください。

各記事のリンクは投稿済みのものから順次埋めていきます。

気軽にメッセージください

技術相談・ご感想・ご質問があればメッセージをお願いします。