😊

Pythonエンジニアが選んだ“最小フルスタック”:htmx + 手動API + JavaScript Islands

に公開

はじめに:Pythonエンジニアがフルスタックで悩むポイント

Pythonでバックエンド開発をしていると、
「フロントエンドも自分で触れたほうがいいのだろうか」
「フルスタックにするなら結局SPAが必要なのでは」
と感じる場面が少なからずあります。

一方で、実際にSPA構成を採用すると、
ビルド環境や状態管理、型の整合性、APIの調整など、
本来注力したかった業務ロジック以外の複雑さが一気に増えることも珍しくありません。

本記事では、Pythonエンジニアの視点から、
JavaScriptを最小限に抑えつつ、フルスタック開発を成立させる構成
として、
htmx・手動設計のPython API・JavaScript Islandsを組み合わせた
実装と設計方針を紹介します。

ReactやSPAを否定する記事ではありません。
あくまで「別の選択肢」として、
段階的に進化できる構成を整理することが目的です。


本記事の前提とゴール

本記事で紹介する構成は、最初から次の前提を置いています。

  • フロントエンドは将来的に置き換え可能である
  • APIの境界は途中で壊さない
  • 構成は成長しても破綻しないことを優先する

そのため、フレームワークへの依存を最小限に抑え、
境界と責務を明確に分離することを重視しています。

個人開発や小規模チームだけでなく、
後から要件や構成が変化するプロジェクトでも
扱いやすい形を目指します。


なぜ「htmx + 手動API + JavaScript Islands」なのか

この構成を選んだ理由はシンプルです。

  • 画面遷移や部分更新はサーバー主導で十分なケースが多い
  • APIはフロントエンド都合ではなく、ドメインの責務単位で設計したい
  • JavaScriptは「必要な場所だけ」に閉じ込めたい

htmxを使うことで、HTMLベースのやり取りを維持しながら、
最小限の記述で画面更新を実現できます。

また、APIを自動生成せず手動で設計することで、
境界を意識した設計を初期段階から行える点も重要です。


全体アーキテクチャ概要

全体は大きく次の3つに分かれています。

  • フロントエンド(htmx / JavaScript Islands)
  • API層(HTTP入出力の責務)
  • ドメイン層(業務ロジック)

htmxはあくまで「APIを利用するクライアントの1つ」であり、
将来別のフロントエンドに置き換えても、
APIとドメイン層は影響を受けない設計を意識しています。


API設計:フロントエンドから独立した境界を作る

APIは画面構成に引きずられず、
業務的な意味を持つ単位で定義します。

たとえば、次のようなシンプルなJSONを返すAPIを考えます。

{
  "id": "task_123",
  "title": "Write article",
  "status": "done"
}

このAPIは、htmxだけでなく、
将来Reactや別クライアントから呼ばれる可能性も想定しています。

そのため、APIの役割は
「入力を受け取り、ドメインロジックを実行し、結果を返す」
ことに限定します。


APIコード例(FastAPI)

ディレクトリ構成例

app/
  api/
    tasks.py
  domain/
    task.py
  infrastructure/
    repository.py
  main.py

domain/task.py

from dataclasses import dataclass

@dataclass
class Task:
    id: str
    title: str
    status: str

    def complete(self) -> "Task":
        return Task(
            id=self.id,
            title=self.title,
            status="done",
        )

infrastructure/repository.py

from app.domain.task import Task

class TaskRepository:
    def get(self, task_id: str) -> Task:
        return Task(
            id=task_id,
            title="Write article",
            status="todo",
        )

    def save(self, task: Task) -> None:
        pass

api/tasks.py

from fastapi import APIRouter
from app.infrastructure.repository import TaskRepository

router = APIRouter()
repo = TaskRepository()

@router.get("/tasks/{task_id}")
def get_task(task_id: str):
    task = repo.get(task_id)
    return {
        "id": task.id,
        "title": task.title,
        "status": task.status,
    }

@router.post("/tasks/{task_id}/complete")
def complete_task(task_id: str):
    task = repo.get(task_id)
    completed = task.complete()
    repo.save(completed)
    return {
        "id": completed.id,
        "title": completed.title,
        "status": completed.status,
    }

main.py

from fastapi import FastAPI
from app.api.tasks import router as task_router

app = FastAPI()
app.include_router(task_router)

フロントエンド構成:htmxとJavaScript Islandsの責務分離

フロントエンドは、次の2層に分けて考えます。

  • htmx:画面遷移・部分更新
  • JavaScript Islands:局所的な振る舞い

JavaScriptは状態管理や全体制御には使わず、
必要な箇所の振る舞いを補助する役割に留めます。


htmxテンプレート例

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>Tasks</title>
  <script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head>
<body>

  <section>
    <h2>Task</h2>

    <div id="task"
         hx-get="/tasks/task_123"
         hx-trigger="load"
         hx-swap="innerHTML">
      Loading...
    </div>
  </section>

</body>
</html>

JavaScript Islands(最小例)

<script>
  document.addEventListener("click", (e) => {
    if (e.target.matches("[data-confirm]")) {
      if (!confirm("Are you sure?")) {
        e.preventDefault();
      }
    }
  });
</script>
<button
  data-confirm
  hx-post="/tasks/task_123/complete"
  hx-target="#task"
  hx-swap="outerHTML">
  Complete
</button>

フロントエンドを将来リプレイスする前提で考える

この構成は、htmxを使い続けることをゴールにしていません。

  1. 初期:htmx + JavaScript Islands
  2. 中期:一部画面のみSPA化
  3. 将来:フロントエンド全面リプレイス

いずれの段階でも、
APIとドメイン層の境界が保たれていれば、
バックエンド側の変更は最小限で済みます。


Python内部の責務分離(API / Domain / Infrastructure)

app/
  api/
  domain/
  infrastructure/
  • api:HTTP入出力・バリデーション
  • domain:業務ルール
  • infrastructure:DBや外部サービス

この構成により、フロントエンドの変更が
ドメインコードに波及するのを防げます。


Domainとは何か:フレームワークから切り離された「判断の中心」

本記事における domain は、
アプリケーションが「何を正しいと判断するか」だけを扱う層です。

ここには次のような特徴があります。

  • HTTPを知らない
  • データベースを知らない
  • フロントエンドを知らない
  • htmxやJSONの形式を知らない

つまり、domain は
「どこから呼ばれたか」「どう表示されるか」ではなく、
業務として成立するかどうかだけを判断します。


なぜDomainを独立させるのか

フルスタック開発では、
画面・API・永続化の都合が業務ロジックに混ざりがちです。

しかし、それらが混ざると次の問題が起きます。

  • フロントエンド変更でロジックが壊れる
  • API形式変更で判断ロジックを修正する必要が出る
  • テストがHTTP前提になり、検証が重くなる

domain を独立させることで、
変更理由を分離できます。

  • 画面変更 → フロントエンド層のみ
  • 通信方式変更 → API層のみ
  • 業務ルール変更 → Domain層のみ

Domainが持つべき責務

Domain層が責任を持つのは、次のような内容です。

  • 状態遷移のルール
  • 業務上の制約
  • 正常/異常の判断
  • 操作の結果どうなるべきか

先ほどの Task を例にすると、
「完了できる」「完了したら状態が変わる」という判断は
Domainの責務です。

@dataclass
class Task:
    id: str
    title: str
    status: str

    def complete(self) -> "Task":
        if self.status == "done":
            raise ValueError("Task is already completed")

        return Task(
            id=self.id,
            title=self.title,
            status="done",
        )

ここでは、

  • APIが呼ばれたかどうか
  • どの画面から操作されたか

といった情報は一切関与しません。


Domainに置かないもの

意図的に、Domainには次のものを置きません。

  • FastAPIの依存
  • リクエスト/レスポンスモデル
  • ORMのモデル
  • DBセッション
  • HTMLやテンプレート

これらはすべて 「外部との接点」 であり、
Domainの判断ロジックとは別の責務です。


Domainを中心に据えるメリット

この構成にすると、次のような利点があります。

  • フロントエンドを全面リプレイスしてもロジックは不変
  • API形式(JSON / HTML / RPC)が変わっても影響が最小
  • 単体テストが容易
  • 実装より先に「判断」を考えられる

結果として、
アプリケーションの成長に耐えられる構造になります。


なぜ「薄いDomain」から始めるのか

最初から複雑な設計をする必要はありません。

本記事のように、

  • 小さなEntity
  • 明確な操作
  • 最小限のルール

から始めても、
domain という境界を先に作っておくことで、
後から責務を自然に追加できます。

重要なのは、
「今どこに書かないか」を決めておくことです。


Infrastructureとは何か:Domainを現実世界につなぐ層

本記事における infrastructure は、
Domainの判断を「実際に成立させるための手段」を担当する層です。

Domainが「何が正しいか」を決めるのに対し、
Infrastructureは「それをどう保存し、どう取得し、どう連携するか」を扱います。

具体的には、次のような要素が含まれます。

  • データベースアクセス
  • 外部API連携
  • ファイル操作
  • キャッシュ
  • メッセージキュー

これらはすべて、
Domainの外側にある現実的な都合です。


なぜInfrastructureを分離するのか

Infrastructureは、アプリケーションの中でも
最も変更されやすい要素です。

  • DBをSQLiteからPostgreSQLに変える
  • ORMを変更する
  • 外部APIの仕様が変わる
  • キャッシュ戦略を見直す

これらの変更が
DomainやAPIに直接影響すると、
アプリケーション全体が不安定になります。

Infrastructureを独立させることで、

  • 技術変更はInfrastructure内に閉じる
  • 業務ロジック(Domain)は影響を受けない

という状態を作れます。


Infrastructureが持つべき責務

Infrastructure層が責任を持つのは、
**「データや外部サービスとのやり取りを、Domainが扱える形に変換すること」**です。

例として、TaskのRepositoryを考えます。

class TaskRepository:
    def get(self, task_id: str) -> Task:
        ...

    def save(self, task: Task) -> None:
        ...

ここで重要なのは、

  • DBのテーブル構造
  • ORMのモデル
  • SQLの書き方

といった詳細は Infrastructureの内部に隠す ことです。

DomainやAPIは、

  • Task を取得できる
  • Task を保存できる

という 意味的な操作 だけを知っていれば十分です。


Infrastructureに置かないもの

Infrastructureは「何でも屋」ではありません。
意図的に、次のものは置きません。

  • 業務ルール
  • 状態遷移の判断
  • バリデーションロジック
  • HTTPリクエスト/レスポンスの解釈

これらはすべて DomainまたはAPIの責務 です。

Infrastructureが判断を持ち始めると、
次第にロジックの所在が分からなくなります。


InfrastructureはDomainに依存してよい

この構成では、

  • Domain → Infrastructure に依存しない
  • Infrastructure → Domain に依存してよい

という 一方向の依存関係 を取ります。

Infrastructureは、

  • DomainのEntityを保存する
  • Domainの型を使って返却する

ことは問題ありません。

一方で、Domainが
「どのDBを使っているか」「どのAPIを叩いているか」
を知る必要はありません。


Repositoryという境界の意味

Repositoryは、
InfrastructureとDomainの間にある 重要な境界 です。

Repositoryがあることで、

  • Domainは「取得・保存」という概念だけを扱える
  • Infrastructureは「具体的な手段」を自由に変更できる

という分離が成立します。

この境界がある限り、

  • DB差し替え
  • インメモリ実装
  • テスト用スタブ

を容易に用意できます。


Infrastructureを薄く保つ理由

Infrastructureは、
複雑になりやすく、劣化しやすい層です。

そのため、

  • 業務判断を入れない
  • 変換と接続に専念する
  • 可能な限り薄く保つ

ことが重要です。

Infrastructureが薄いほど、
アプリケーション全体の寿命は長くなります。


正確な責務分解:API / Domain / Infrastructure

ここまで紹介してきた構成を、
責務という観点でより明確に整理します。

この3層はそれぞれ役割がはっきり分かれており、
どこまでを担当し、どこからを担当しないかが重要です。


API:入口・構造・翻訳

API層は、アプリケーションの入口です。

役割

  • HTTPリクエストの受け取り
  • 入力の構造チェック(型・必須項目)
  • 外部形式(JSONなど)から内部形式への変換
  • Domain層の呼び出し

やらないこと

  • 業務ルールの判断
  • 永続化の判断
  • 技術選定に依存したロジック

APIは、
「何が送られてきたかを理解し、正しい形にして渡す」
ことだけに集中します。

👉 APIは 「受付係+翻訳者」 です。


Domain:判断・ルール・整合性

Domain層は、
アプリケーションにおける 判断の中心 です。

役割

  • 業務ルールの検証
  • 状態遷移の可否判断
  • 「正しい/正しくない」の決定
  • 操作の意味付け

やらないこと

  • HTTPやJSONを知る
  • DBや外部APIを知る
  • 実際の保存や送信を行う

Domainは、
「どこから呼ばれたか」「どう表示されるか」ではなく、
業務として成立するかどうかだけを判断します。

👉 Domainは 「裁定者」 です。


Infrastructure:接続・変換・実行の「手段」

Infrastructure層は、
Domainの判断を 現実世界に反映するための手段 を提供します。

ここで重要なのは、
Infrastructureは「実行そのもの」を判断しないという点です。

正確な役割

  • Domainが下した判断を現実世界に反映するための手段を提供する
  • DB・外部API・ファイル・キューへの接続
  • 技術的な副作用の実装

やらないこと

  • 業務ルールの判断
  • 状態遷移の可否決定
  • API形式の解釈

Infrastructureは、
「どう保存するか」「どこに送るか」「どう接続するか」
といった 技術的な実装詳細 に専念します。

👉 Infrastructureは 「手足・接続装置」 です。


「実行」はどこで起きているのか

補足として、よくある誤解を整理します。

  • API:実行フローを組み立てる
  • Domain:実行してよいかを判断する
  • Infrastructure:判断結果を現実世界に反映する手段を提供する

Infrastructureは自律的に何かを実行するわけではなく、
呼ばれて初めて副作用を発生させます。


この責務分解がもたらすもの

この分離を守ることで、

  • フロントエンドを差し替えてもDomainは影響を受けない
  • 技術スタック変更がInfrastructureに閉じる
  • 業務ルールの所在が明確になる

という状態を作れます。

結果として、
アプリケーションは成長しても壊れにくくなります。


この構成が向いているケース / 向いていないケース

向いているケース

  • 個人開発や小規模チーム
  • 管理画面や業務システム
  • MVPや段階的な機能追加

向いていないケース

  • 高度なリアルタイム性が必要なUI
  • 複雑なクライアント側状態管理が必須なアプリ

まとめ:Pythonエンジニアにとっての「最小フルスタック」

本記事で紹介した構成は、
「フルスタックになること」自体を目的にしたものではありません。

Pythonエンジニアが、

  • 業務ロジックに集中できること
  • フロントエンドを必要以上に抱え込まないこと
  • それでもアプリケーション全体を把握できること

この3点を満たすための、現実的に成立する最小構成です。

最初からすべてを完璧に設計する必要はありません。
しかし、API・Domain・Infrastructure という境界と責務だけは早い段階で固定しておくことで、
フロントエンドや技術スタックが変わっても、構成は安全に進化できます。

今回紹介した構成は、
Pythonエンジニアが無理なくフルスタックを扱うための
一つの現実的な選択肢です。


Discussion