📖

Domain-Driven Design(DDD)によるバックエンド構築:考え方と押さえるべき要点

に公開

はじめに ✨

みなさんこんにちは。今回は少し“歯ごたえのある”テーマに挑戦します。
それが DDD(Domain-Driven Design/ドメイン駆動設計) です。DDD は学ぶ価値の高いアーキテクチャだと感じています。私自身の実務経験はまだ長くありませんので、不十分な点もあるかもしれません。ぜひご指摘・フィードバックをいただければ幸いです。

Domain-driven design (DDD) is an approach to developing software for complex needs by deeply connecting the implementation to an evolving model of the core business concepts.

DDD は「特定フレームワークの型」ではなく、業務ドメインを中心に思考を整理するための方法論です。本稿では、主要な概念、守るべき原則、そしてプロジェクトが大きくなってもコードが混乱しないための考え方をまとめ、最後に Rust のコードベースでの 1 つのユースケース を通して具体化します。想定するディレクトリ構成は次のとおりです。

domain/          # model (Entity/VO), repository (ports), domain services
application/     # usecases (orchestration, transaction boundary)
infrastructure/  # adapters (DB/Keycloak/Mail/Storage/Webhook)
interface/       # web handlers, middleware, routes, batch
registry/        # wiring/DI: bind ports ↔ adapters
shared/          # cross-cutting: error, time, ids...

戦術的 DDD とは? なぜこのアーキテクチャを選ぶのか? 🤔

まず、本記事で主に扱うのは 「戦術的 DDD」 です。

戦術的 DDD は、ドメインモデルを具体的なコードとして実装するための 設計パターン群 です。
業務の中核概念を Entity / Value Object / Aggregate としてモデル化し、Use Case / Application Service でユースケースをオーケストレーションします。

DB・HTTP・IdP・メールなどのインフラは、ドメインが定義した Port(本記事では Rust の trait で実装する Repository / IdProvider / Mailer …) に接続する Adapter として扱います(いわゆるヘキサゴナルアーキテクチャ/ポート&アダプターパターン)。

一方で、その根底にある DDD の思想 は「ドメイン(業務領域)を中心に設計する」という考え方です。

なぜドメインを拠り所にするのか? それは顧客(ドメインエキスパート)が最も深く理解している対象だからです。私たちは顧客の要件に沿って開発するため、システム要件を顧客ほど理解している人はいません。顧客がシステムを説明するときはドメインの言葉で語ります。ゆえにドメインを中心に据え、誰もが共有できるモデルへ落とし込むのが私たちの仕事です。

端的に言えば、DDD とはエンジニアだけでなく非技術者である顧客も核心を掴めるように設計することです。

戦術的 DDD を実践する上で最も難しいのは、概念を正しく見極め、各要素を 然るべき場所(レイヤや境界) に配置することです。

なぜ戦術的 DDD をを選ぶのか(特に Rust で) 🙌

  • 個々の要素の肥大化を防ぐ: ドメインと I/O を分離し、責務の混在を抑える。
  • テストしやすく、置き換え容易: Port/Adapter でモック検証・インフラ差し替えが簡単。
  • 境界が明確: Bounded Context で用語・責務を切り分け、衝突を減らす。
  • Rust と相性良: 強い型+ trait + enum で不変条件とエラーを明快に表現。

戦術的 DDD を使うべき場面 ✅

  • 複雑で変化の多いドメイン、長寿命のプロダクト
  • 監査性/整合性が重要(マルチテナント、権限、ワークフロー等)
  • 複数チームが並行開発し、明確な境界が必要

戦術的 DDD が不要 な場面 🙅

  • 小さな補助ツール、単純な CRUD 管理、短期運用
  • 超短期 MVP(ただし将来 戦術的 DDD へリファクタリングする“着地点”は意識しておく)

戦術的 DDD で守るべきルール 📖

戦術的 DDD を運用していく中で、「コードをきれい・一貫・保守しやすく保つ」ための実践ルールをまとめました。👇

  • Domain はフレームワーク/インフラを import しない
    Entity / VO、業務ルール、Port(本記事では Rust の trait で実装)のみ。sqlx や axum など外部 SDK 禁止。

  • Use Case に SQL/HTTP を書かない—Port/Repository 経由のみ
    Application 層はオーケストレーションとトランザクション制御に専念。

  • Handler は“薄く”
    入力の parse/validate → Use Case 呼び出し → エラーを HTTP/DTO に map。業務ロジックは置かない。

  • エラーマッピングを明確に(AppError / RepoError)
    DB/HTTP の生エラーをドメイン外へ漏らさない。enum で整理し、段階的に変換。

  • トランザクション境界は Application(Unit of Work)
    複数リポジトリに跨る場合は Use Case で BEGIN/COMMIT/ROLLBACK を管理。Repo 側で勝手に開かない。

  • レイヤごとにテスト: Domain(純粋・高速)→ Application(fake/in-memory repo)→ Infrastructure(統合)→ Interface(HTTP)
    の順に、内側で軽い層から外側で重い層へと広げていく(テストコストが低い順)。

  • ユビキタス言語を徹底
    型名/関数名は“業務用語”で。技術寄りの曖昧な命名は避ける。

  • 新サービス追加の手順は一定に
    Port(domain)→ Use Case → Adapter(infra)→ Wiring(registry)→ Test の順で小さく完結。

コーディング時・サービス追加時に迷子にならない思考法 🧠

つぎは、戦術的 DDD を実務で一番むずかしく感じるポイント ――「実装の順番」「責務の置き場所」「増築時の迷子回避」―― についてです。
“何から手をつけ、何をどこに置けばよいか?” を、手順化しておきます。

以下の手順と、それを実際のスタディケースにどう適用するかを見ていきましょう。
ここでは、アプリに新しいエンドポイントを実装するケースを例にします。

POST /contents/:id/publish — 記事を draft から published に変更し、その後に Webhook 送信 と メール通知 を行います。

Step 0: コード前に「3 行要件」を書く

  • 入力: contentId
  • 期待結果: 記事が Published になり、204 No Content を返す
  • 失敗: 存在しない/既に公開済み/DB エラー/Webhook・メール送信エラー

目的:コードに手を付ける前に、開発者・非開発者の双方が同じ理解を持つこと。

Step 1: Domain 先行 (モデルとルール)

  • 既存 Content に publish() ルールを追加:Draft → Published のみ可、title 必須
  • ここで SQL/HTTP を入れない
// domain/model/content.rs
use crate::model::content::ContentStatus;

impl Content {
  pub fn publish(&mut self) -> Result<(), &'static str> {
    if self.status == ContentStatus::Published { return Err("already published"); }
    if self.title.is_empty() { return Err("title required"); }
    self.status = ContentStatus::Published;
    Ok(())
  }
}

Step 2: Port(trait)を定義 (I/O を追い出す)

  • 最小集合:ContentRepository / WebhookPublisher / MailSender
  • 戻り値は ドメイン型 + ドメイン側エラー に限定(SQL/HTTP の生エラーはここに出さない)
// domain/src/repository/content_repository.rs
#[async_trait]
trait ContentRepository {
  async fn find_by_id(&self, id: &ContentId) -> Result<Content, RepoError>;
  async fn save(&self, content: &Content) -> Result<(), RepoError>;
}

Step 3: Use Case を書く(オーケストレーションだけ)

  • 取得 → 公開 → 保存 →Webhook→ メールの順番を定める
  • SQL/HTTP は禁止、Port 呼び出しのみ。トランザクション境界もここで
// application/src/usecase/content.rs
impl ContentUsecase {
    pub async fn publish(&self, id: &ContentId) -> Result<(), AppError> {
        let mut content = self.repo.find_by_id(id).await?;
        content.publish().map_err(AppError::Validation)?;
        self.repo.save(&content).await?;
        self.webhook.publish_content(id).await?;
        self.mail.send_published_notice(id).await?;
        Ok(())
    }
}

Step 4: Adapter(インフラ詳細はここだけ)

  • ContentRepository を sqlx(Postgres)で実装、エラーは RepoError へ変換
  • WebhookPublisher / MailSender は HTTP/SMTP を叩き、AppError::Infra へ正規化

DB/HTTP の具体はすべて Adapter に隔離。 Domain/Application に漏らさない。

// infrastructure/src/database/content_repository_pg.rs
#[async_trait::async_trait]
impl ContentRepository for ContentRepositoryPg {
    async fn find_by_id(&self, id: &ContentId) -> Result<Content, RepoError> {
        let rec = sqlx::query!(
            r#"
            SELECT id, title, body, status
            FROM contents
            WHERE id = $1
            "#,
            id.0
        )
        .fetch_optional(&self.pool)
        .await
        .map_err(|e| RepoError::Infra(e.to_string()))?;

        let r = rec.ok_or(RepoError::NotFound)?;
        let status = match r.status.as_str() {
            "published" => ContentStatus::Published,
            _ => ContentStatus::Draft,
        };

        Ok(Content {
            id: ContentId(r.id),
            title: r.title,
            body: r.body,
            status,
        })
    }

    async fn save(&self, c: &Content) -> Result<(), RepoError> {
        let status = match c.status {
            ContentStatus::Published => "published",
            ContentStatus::Draft => "draft",
        };

        sqlx::query!(
            r#"
            UPDATE contents
            SET title = $1, body = $2, status = $3
            WHERE id = $4
            "#,
            c.title,
            c.body,
            status,
            c.id.0
        )
        .execute(&self.pool)
        .await
        .map_err(|e| RepoError::Infra(e.to_string()))?;

        Ok(())
    }
}

Step 5: Registry(配線)

  • 本番は ContentRepositoryPg / WebhookHttp / MailSmtp を PublishContentUsecase に注入
  • テスト時は fake / in-memory に差し替え可能に
// registry/src/lib.rs
use std::sync::Arc;
use sqlx::PgPool;

use application::usecase::content::ContentUsecase;
use infrastructure::{
    database::content_repository_pg::ContentRepositoryPg,
    webhook::publisher_http::WebhookPublisherHttp,
    mail::sender_smtp::MailSenderSmtp,
};

pub struct AppRegistry {
    pub content_usecase: ContentUsecase,
}

impl AppRegistry {
    pub fn new(pool: PgPool, webhook_url: String, smtp_url: String, from: String, to: String)
        -> anyhow::Result<Self>
    {
        let repo    = Arc::new(ContentRepositoryPg { pool });
        let webhook = Arc::new(WebhookPublisherHttp::new(webhook_url));
        let mail    = Arc::new(MailSenderSmtp::new(&smtp_url, from, to)?);

        let content_usecase = ContentUsecase::new(repo, webhook, mail);

        Ok(Self { content_usecase })
    }
}

Step 6: HTTP Handler(薄く保つ)

// interface/src/web/handler/content.rs
use axum::{extract::{Path, State}, http::StatusCode};
use crate::registry::AppRegistry;
use domain::model::content::ContentId;

#[utoipa::path(
    post,
    path = "/contents/{id}/publish",
    params( ("id" = String, Path, description = "Content ID") ),
    responses( (status = 204, description = "Publish content success") ),
    tag = "contents",
)]
#[tracing::instrument(skip_all)]
pub async fn publish_content(
    State(registry): State<AppRegistry>,
    Path(id): Path<String>,
) -> Result<StatusCode, AppError> {
    let content_id = ContentId(id);
    registry.content_usecase.publish(&content_id).await?;
    Ok(StatusCode::NO_CONTENT)
}

Step 7: テストは層ごと(内 → 外)

  1. Domain:publish() の成功/失敗
  2. Application:fake repo/webhook/mail でハッピーパス&エラー分岐
  3. Infrastructure:必要箇所のみ DB/API 統合テスト
  4. Interface:エンドポイントが 204/4xx を正しく返すか

まとめ

本稿では、DDD をドメイン中心の思考法として捉え、Rust での戦術的 DDD の最小パターン(Domain→Port→Use Case→Adapter→Wiring→Handler→Test)と、POST /contents/:id/publish のスタディケースで具体化してみました。ポイントは 「ルールは Domain に、調停は Use Case に、I/O は Adapter に」 の分離です。

読んでいただきありがとうございます。まだ荒削りな部分もあると思いますが、みなさんの現場で少しでも指針になればうれしいです。ご意見・質問・改善アイデアなど、ぜひ気軽にフィードバックください。今後も実践からの学びを共有していきます!

GitHubで編集を提案
Sun* Developers

Discussion