0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「なんとなくドメインモデル」から抜け出す — アーキ部@札幌に参加してきた

0
Posted at

8月29日、札幌で開かれた「アーキ部@札幌!ドメインモデリングを学ぼう!」に参加してきました。Javaユーザーグループ北海道(Javaどうでしょう)の主催、ゲストは川島義隆さん(株式会社ウルフチーフ)。普段オンラインのアーキ部が、地方コミュニティとのコラボでオフライン開催された回です。

セッションのタイトルは「『なんとなくドメインモデル』から抜け出すための本物のドメインモデリング入門」。冒頭がこれでした。

今日この会が終わったら、レイヤーがどうとか、ビジネスロジックがどうとかいう世界観は一切忘れてください。

— 当日のセッションより

「洗脳される」と自分で言っていて、まあまあ強めの入りです。だいたい洗脳されて帰ってきました。

資料とサンプルコードは公開されています。kawasima/boundaries-not-layers(© 2026 Yoshitaka Kawashima, MIT License)に、当日のスライド slide-202608.md と、同じカート機能を5通りの設計で実装した examples/ が入っています。以下のコードは、断りのない限りスライドに載っていた短縮版からの引用です(元のファイルへのリンクも併記します)。

イベント概要

項目 内容
イベント 【札幌8/29】アーキ部@札幌!ドメインモデリングを学ぼう!
日時 2026年8月29日(土)13:00〜17:30
主催 Javaユーザーグループ北海道(11周年・36回目)
ゲスト 川島義隆さん(株式会社ウルフチーフ / アーキ部主催)
資料 kawasima/boundaries-not-layers

13:30からメインセッション、14:45から2時間のモデリング・実装ワークショップ、最後に成果発表という構成です。会場はクオリサイトテクノロジーズさんの提供。最初にスポンサーセッションがあり、沖縄の名護と札幌の2拠点でニアショア・ダイレクトモデルをやっている会社だと知りました。

症状:業務の決めごとがモデルの外へ出ていく

Value Object、Entity、Repository、Domain Service。ドメインモデリングとはこういう部品を並べることだ、と読める情報は少なくありません。型は増え、パッケージも分かれ、レビューも通る。それでも業務の決めごとがモデルのどこにも書かれていない、ということが起きる。

業務で決まっていること コードでの置き場 起きること
どの値が有効か DTOのアノテーション ドメインの型は不正な値を作れる
どの状態の組み合わせがありうるか 区分フィールドと null 型にない組み合わせが実行時に現れる
どの操作が許されるか Service の if 呼出側は条件を知らないと使えない
業務上の却下がどう伝わるか 例外・boolean 扱いを書かなくてもコンパイルが通る
いま何時か・DBに何があるか DIコンテナ 判断とI/Oが同じ関数に入る

出典: slide-202608.md

うまいと思ったのは、この各行を動くコードで見せていったところです。抽象論ではなく examples/ を開いて「ここです」と指す。

1行目、どの値が有効か。注文者は個人か法人で、法人なら法人番号が必須です。この業務ルールはフィールド単位の制約では書けないので、@AssertTrue のクロスフィールド検証を人手で足すことになります。項目が増えるほどこの手当ても増える。そしてそれは Web の型に付いた検査で、ドメインの型は何も知りません。

2行目、どの状態の組み合わせがありうるか。

// domain 層。区分と、個人・法人 両方の項目をフラットに持つ
public record Orderer(OrdererType type, String email,
                      String name, String companyName, String corporateNumber) {}

// 法人なのに法人番号が無い注文者が、そのまま作れる
new Orderer(OrdererType.CORPORATION, "a@b.com", null, null, null);

出典: slide-202608.md(元: performance-purity/domain/Orderer.java

区分は本来、境界で個人か法人かを見分けるためのタグで、ドメインの状態ではない。型を分けないと、その区分をドメインまで持ち込むことになる。

slide-202608.md

type フィールドを何の疑問もなく持ち回っていたので、ここで刺されました。

残りの3行も同じ調子で続きます。数量が正であることを境界とドメインで二度検査している。カートの上限判断が Cart ではなくユースケースにある。販売終了もカート満杯も業務上ありうる結果なのに例外で飛んでいる。見積の有効期限を LocalDate.now() でその場で読み、上限を判断するためにドメインの内側からリポジトリを呼んでいる。全部身に覚えがありました。

トリレンマを、3つの実装で見せる

ドメインモデルには完全性・純粋性・性能があり同時に満たしにくい、というよく知られた議論があります。川島さんはこれを、同じカート機能の「合計数量が上限を超えない」の守り方だけ変えた3実装で見せました。

実装 諦めたもの どこに出るか
performance-completeness 純粋性 CartForUpdate.add が内側からリポジトリを呼ぶ
performance-purity 完全性 上限の判断がユースケースにある
purity-completeness 性能 追加のたびに全アイテムをロードする

出典: README / slide-202608.md

3つとも動くので、「諦めたもの」がどの行に現れるか自分で確かめられます。そして結論はこうでした。

純粋性を「ドメイン層がI/Oを実行してよいか」というレイヤーの性質として捉えている限り、この三択から出られない。(中略)純粋性は、単一の責務を持つ振る舞いの性質として捉え直す。

slide-202608.md

三択のどれを取るかという問いを、立て方ごと変える。ここが本題でした。

決めるのは、クラスの分類ではない

資料はこう整理していました。

  • どの値が有効か
  • どの結果がありうるか
  • 外部世界とどこで接するか

この3つを型で閉じる。ドメインモデルを業務を説明するクラス群として扱うのではなく、業務規則によって可能な状態と遷移を定める、実行できるモデルとして扱う。

slide-202608.md

さきほどの症状表の5行が、そのままこの3つに畳まれています。バリューオブジェクトかエンティティか、という問いはここに含まれていません。

境界を越えるとは、型を変えること

  • validate: 値はそのまま。正しさはフラグとして得るだけで、型には残らない
  • parse: 正しければドメインの型になる。以降は型が正しさの証明になる

slide-202608.md

Parse, Don't Validate を Java でやるためのライブラリが、川島さん作の Raoh(Apache-2.0)です。失敗は例外ではなく Result の値で返し、どのフィールドで失敗したかをパス付きで蓄積する。record・sealed・パターンマッチ前提のAPIで、成果物が Java 25 ビルドなので実行にも 25 が要ります。

public sealed interface Result<T> permits Ok, Err {}
public record Ok<T>(T value)        implements Result<T> {}
public record Err<T>(Issues issues) implements Result<T> {}

出典: slide-202608.md(資料の簡略表記。実物は raoh/Result.java ほか。Apache-2.0)

Ok/Err は record(値がそろう AND)、Result は sealed(どちらか OR)。この AND と OR の対比は資料で何度も出てきます。積と和で型を組み立てる、と一度言葉にしてもらうと、以降のコードの読み方が変わりました。

record × sealed で、不正な組み合わせを作れなくする

注文者も同じ道具で書けます。

public sealed interface Orderer permits Orderer.Individual, Orderer.Corporation {
    record Individual(Email email, String name) implements Orderer {}
    record Corporation(Email email, String companyName,
                       String corporateNumber) implements Orderer {}
}

出典: slide-202608.md(元: raoh/domain/Orderer.java

「法人なのに法人番号が無い」は型として存在できません。境界のデコーダはこうなります。

public static final Decoder<JsonNode, Orderer> ORDERER = discriminate("type",
        variant("individual", INDIVIDUAL),
        variant("corporation", CORPORATION));

static final Decoder<JsonNode, Orderer.Corporation> CORPORATION = combine(
        field("email", string().trim().toLowerCase().email().map(Email::new)),
        field("companyName", string().trim().nonBlank().maxLength(200)),
        field("corporateNumber", string().pattern(Pattern.compile("\\d{13}")))
).map(Orderer.Corporation::new);

出典: slide-202608.md(元: raoh/web/JsonOrdererDecoders.java

さっきの @AssertTrue は要らなくなります。組み合わせの保証が型に移ったからです。エラーは途中で止まらず、Err[/companyName: 空です, /corporateNumber: 13桁の数字ではありません] のようにまとめて返ります。

数量も同じで、int_().positive() を通った値が Quantity になる。内側に Quantity があること自体が検査を通った証拠なので、requirePositive が二度出てくることはありません。

見積が法人限定、というルールは引数の型に出ます。

public class IssueQuote implements BiFunction<UserId, Orderer.Corporation, Result<Quotation>> {

出典: raoh/domain/IssueQuote.java

個人を渡す経路が型として存在しないので、実行時の if は境界の switch へ移ります。業務ルールが1つ、コードから消えて型に移ったことになる。

I/O は、振る舞いの外で合成する

トリレンマの答えがここです。

public Result<CartItem> apply(UserId userId, ProductId productId, Quantity quantity) {
    return products.load(productId)                          // gateway: 商品を読む
            .flatMap(Product::ensureOnSale)                  // 振る舞い: 販売中か
            .flatMap(product -> carts.loadForUpdate(userId)  // gateway: カートを読む
                    .flatMap(cart -> cart.add(productId, quantity)  // 振る舞い: 上限内で追加
                            .map(item -> {                   // gateway: Ok のときだけ書き込む
                                carts.addItem(cart.id(), item);
                                return item;
                            })));
}

出典: slide-202608.md(元: raoh/domain/AddItemToCart.java

判断は外界に触れない純関数、I/Oは gateway、合成だけをこの関数が受け持つ。そして Cart が持つのは、識別子と、判断に要る合計数量だけです。

public record Cart(CartId id, TotalQuantity currentQuantity) {
    public static final int UPPER_BOUND = 10000;

    // リポジトリを受け取らない。合計数量は境界で decode 済み
    public Result<CartItem> add(ProductId productId, Quantity quantity) {
        if (!currentQuantity.canAdd(quantity, UPPER_BOUND))
            return Result.fail(Path.ROOT, "cart_full", "商品数の上限に達しています");
        return Result.ok(new CartItem(productId, quantity));  // 通過して初めて構築する
    }
}

出典: slide-202608.md(元: raoh/domain/Cart.java

全アイテムをロードしないので速く、上限は自分で判断でき、add は純粋。三択が同時に成り立ちます。「集約は全部ロードするもの」だと思い込んでいたので、ここは膝を打ちました。判断に要る分だけを持てばよかった。

コントローラーに残るのは、デコードして振る舞いを呼び、結果をHTTPに写すことだけです。デコード失敗は 400、デコードは通ったが業務上できない場合は Err を 422 に写す respondTo へ、と行き先を2段に分けています(raoh/web/CartController.java)。JSONを JsonNode のまま受けているので、エンドポイントごとのリクエスト型も要りません。

それでも残る問題と、Souther

資料は正直に「まだ残っている」と続けます。Result.fail(Path.ROOT, "cart_full", ...) の失敗の中身は文字列コードのままで、カート満杯と販売終了を別々に扱いたくなってもコンパイラは助けてくれない。症状表の5行目、見積の有効期限を LocalDate.now() で読む箇所も残ったままです。

これを片付けるのが Souther です。ドメインを data / invariant / behavior で書く小さなJVM言語で、Java の record・sealed・decoder・encoder が生成されます。以下のコードは examples/raoh-souther から。5実装の5つ目にあたるもので、さきほどの raoh 版の完全移植で、ドメインだけを Souther 生成に置き換えたものです。

Raoh(ラオウ)と Souther(サウザー)で北斗の拳です。Zod の語源がアメコミのヴィランらしいと聞いてそういう世界観かと思って付けた、とのことでした。ご本人は北斗の拳を通っていないそうです。

値の成立条件は invariant に書きます。

data Quantity = Int
    invariant value > 0

// カートに入れようとしている明細。上限を超えないことは、この型の成立条件
data PendingItem = { cart: Cart, item: CartItem }
    invariant withinCapacity = cart.currentQuantity.value + item.quantity.value <= 10000

出典: slide-202608.md(抜粋。全体は raoh-souther/cart.sou

さきほど Java で Cart.UPPER_BOUND として書いていた 10000 が、ここでは型の成立条件になっています。そして、ありうる結果を並べる。

behavior addItemToCart : (userId: UserId, productId: ProductId, quantity: Quantity)
        -> ItemAdded | ProductNotFound | SaleEnded | CartFull
    depends on loadProduct, loadCart, saveItem

let addItemToCart (userId, productId, quantity, loadProduct, loadCart, saveItem) =
    match loadProduct(productId) with
        | ProductNotFound -> ProductNotFound
        | Product { id, onSale, price } -> {
            guard onSale else SaleEnded
            let c = loadCart(userId)
            guard PendingItem { cart = c, item = CartItem { productId = productId, quantity = quantity } } as pending
                else | withinCapacity -> CartFull
            saveItem(pending)
        }

出典: raoh-souther/cart.sou

成功も却下も同じ並びのデータで、例外はありません。上限の判断は guard の条件ではなく PendingItem の成立条件に置かれていて、構築を試みて成立しなければ CartFull へ抜ける。ルールが型に1回だけ書かれます。

呼び出し側は4ケース全部を switch で書くことになります。資料の言葉を借りれば、behavior の結果に却下をひとつ足すとこの switch がコンパイルエラーになり、業務ルールが増えたことが扱い漏れとして呼び出し側に出る。

なお、Souther 化しても境界のデコードは Raoh の Result のままです。境界の失敗(形式が違う)とドメインの結果(業務上できない)は、別のものとして扱われます。

外部世界には実装を書きません。let を書かないことが「外から与えられる」という宣言になり、実装は Java(jOOQ)から注入される。現在時刻も境界で読んで引数で渡すので、モデルの中に時計は現れません。残っていた LocalDate.now() がここで消えます。

例をモデルに書いて、DBなしで確かめる

fake で依存の代役を立て、example で期待する結果を書きます。

fake loadProduct
    | (ProductId("p-1")) -> Product { id = ProductId("p-1"), onSale = true, price = Money(1000) }
    | (ProductId("p-2")) -> Product { id = ProductId("p-2"), onSale = false, price = Money(500) }
    | _ -> ProductNotFound

example addItemToCart
    | "セール中の商品はカートに追加できる" :
        (UserId("u-1"), ProductId("p-1"), Quantity(2)) -> ItemAdded { productId = ProductId("p-1"), quantity = Quantity(2) }
    | "セール終了の商品は追加できない" :
        (UserId("u-1"), ProductId("p-2"), Quantity(1)) -> SaleEnded

出典: slide-202608.md(抜粋。実際の cart.soufake 4節・example 6節)

これがコンパイル時に評価され、期待と違えばビルドが落ちます。DBもモックも要らず、書いた例はそのまま業務の説明として読める。

さらに、例が足りているかをモデルに問えます。

$ souther examples src/main/souther/cart.sou
  addItemToCart            implemented   rows 6    pending 0
    signature   out specified 4/4  observed 4/4  verified 4/4
    border      borders 3   coverage items 6/6   excluded 6
    branch      6/6

adequacy: satisfied

出典: slide-202608.md

数えているのはテストのカバレッジではなく、モデルが述べたことです。出力と入力の直和のケース(signature)、invariantguard が引いた境界(border)、本体の分岐(branch)。この抜粋には出ていませんが、入力の同値クラスを見る partition という指標もあります。モデルが線を引いていない位置は、埋めるべき穴として数えません。

この考え方は解説記事のほうで、こう説明されています。

裏を返すと、レポートの穴はテストの不足ではなくモデルの不足を指していることが多くあります。同値クラスが埋まらないのは、まだ決まっていない業務ケースがあるからで、境界が埋まらないのは、ルールが曖昧だからです。

Souther - ドメインモデルをスラスラ書けることを追い求めた最果てのJVM言語

レポートのギャップが、そのまま次に業務側へ聞く質問のリストになる。この日いちばん持ち帰りたくなった発想でした。

なお、この解説記事は v0.1.0-rc4 時点のもので、指標名が boundary になっています。当日デモした v0.1.0 では border に変わっているので、読み比べるときは注意が要ります。

ワークショップ

14:45から2時間、手を動かす時間でした。環境要件は JDK 25。

最初は温度による適温判定です。閾値が2つあって、24.0未満なら低い、24.0〜25.9なら適温、26.0以上なら高い。それだけのモデルなのですぐ書けます。souther examples を叩くと3つの区分と2つの境界が認識され、区分を3つ埋めただけでは足りない、境界の線上とその隣にも行が要る、と言われる。期待値を間違えると「期待した結果と、モデルが返した結果が違う」と指摘されます。テストが間違っているのかモデルが間違っているのか、人間が判断する。この対話が思ったより気持ちよかったです。

後半は出張申請。事前承認の要否が3条件の組み合わせで決まります。

  • 出張費用が10万円を超える
  • 役職のない一般社員である
  • 費用を先方が負担する(癒着リスクがあるため)

状態も持ちます。申請準備中 → 提出済 → 承認 → 精算 → 確定、途中でキャンセルもある。これはステータスというフラグではなく状態そのものだ、と言っていました。巨大なクラスにカラムを足して「この状態ならこの項目が必須」を暗黙のルールにするのではなく、状態ごとに型を分ける。午前中の Orderer の話が、そのまま状態遷移に効いてきます。

AIコーディングとの接続

逆をやっていた自覚があります。

大体みんなできていないんですけど、外側の仕様だけ作って(中略)結局中身がないまま全部任せている状態になっている。

— 当日のセッションより

勧めていたのはその逆で、先に内側の安全なモデルを人間が作る。モデルさえあれば、そのままエージェントに渡してコントローラーやDBアクセスを書かせればいい。外側は安全でない領域なので、そこまで丁寧に設計しなくていい、という割り切りです。

Souther は学習データにない言語なので、MCPサーバが同梱されています。souther mcp を登録しておけば、エージェントが構文を推測せずに仕様を引ける。

採用ハードル

Souther も Raoh も JDK 25 が必須です。生成される .class も Java 25 のバージョンなので、動かすアプリの実行にも 25 以降が要る。17 や 21 が主流の現場では、ここが最初の壁になります。

Souther の v0.1.0 が正式リリースされたのは、このイベント当日の 2026年8月29日でした。プロダクション採用事例は、私が調べた範囲では見つかりません。すぐ本番に入れる話ではないです。

一方 Raoh は単体でも使えます。examples/raoh は Java だけで書かれていて、そこまでで既にトリレンマは解けている。手前から試すならこちらでしょう。

持ち帰ったもの

いちばん効いたのはトリレンマの解き方でした。完全性・純粋性・性能のどれを諦めるかという問いを、純粋性をレイヤーの性質ではなく振る舞いの性質として捉え直すことで解消してしまう。判断とI/Oを分けて合成し、ドメインが持つ状態は判断に要る分だけに絞る。

自分のコードで最初に手を付けるとしたら、区分フィールドだと思います。type と null 許容フィールドを並べた record が心当たりだけで3つほどあって、あれを sealed に割ると、その周りに散っている if がどれだけ消えるのか見てみたい。Souther まで行かなくても、Raoh を入れるところまでは月曜からできます。

AIの使い方も変わりそうです。内側のモデルを人間が握って外側を書かせる、という順序は、いま自分がやっている進め方とちょうど反対でした。

Souther を使わなくても、あの3つの問いは今日の設計に持ち込めます。冒頭の「レイヤーのことは忘れてください」も、レイヤーを否定したかったのではなく、層の名前を考える時間を業務の理解に回せ、ということでしょう。

Javaどうでしょうのみなさん、川島さん、クオリサイトテクノロジーズさん、ありがとうございました。

参考リンク

※ 当日のメモと公開資料をもとに書いています。「当日のセッションより」とある引用は、筆者がメモから文意を整理したもので逐語ではありません。出典URLがあるものが公開資料からの引用です。誤りがあればご連絡ください。

0
1
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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?