Alexis Mabanza @ alexvolkihar.ovh

Atomic Designを極める:コピペからデザインシステムへ

Jul 28 · 18min

English Version · Version Française

スライド: SPA(フランス語ごのみ)

Slidev で作成さくせい - presentation slides for developers.

UIは、アプリケーションの中なかで最もっとも雑ざつに扱あつかわれがちなレイヤーだ。納期のうきに追おわれながら「このページだけ」マークアップのブロックを複製ふくせいし、「この場合ばあいだけ」ユーティリティクラスを追加ついかする。半年はんとし後ご、デザインチームからボタンの角丸かどまるを変かえてほしいと言いわれて初はじめて気きづく。プライマリボタンの実装じっそうが14通とおりも存在そんざいし、23個このファイルに散ちらばり、微妙びみょうに違ちがう青あおが7色しょくもあることに。

これはアーキテクチャを持もたないインターフェースの症状しょうじょうだ。フレームワークに密みつ結合けつごうしたビジネスコードとまったく同おなじ問題もんだいが、プレゼンテーション層そうに形かたちを変かえて現あらわれているにすぎない。

ここで登場とうじょうするのがAtomic Designである。Brad Frostが2013年ねんに提唱ていしょうし、2016年ねんの同名どうめいの著書ちょしょで発展はってんさせたこのモデルは、インターフェースをページの集合しゅうごうとしてではなく、階層かいそう化かされ、再さい利用りよう可能かのうで、単体たんたいでテストできるコンポーネントのシステムとして捉とらえることを提案ていあんする。

この記事きじは、そういう種類しゅるいのページから出発しゅっぱつし、モデルの5つのレベルを一通ひととおり見みたうえで、同おなじシステムを2回かい作つくる。1回かいはサーバー側がわのSymfony UX Twig Componentsで、もう1回かいはクライアント側がわのVue 3で。あえて2回かい作つくるのがポイントだ。このモデルがどちらのフレームワークにも依存いぞんしないことを示しめすためである。


1. 出発しゅっぱつ点てん:コピペで作つくられたインターフェース

まずは、ひとかたまりで書かかれた商品しょうひん一覧いちらんを見みてみよう。特とくに変かわったところはない。

{# templates/catalog/list.html.twig #}
<section class="py-8 px-6">
    <h2 style="font-size: 24px; font-weight: 700; color: #1a1a1a; margin-bottom: 24px;">
        Our products
    </h2>

    <div class="grid grid-cols-3 gap-6">
        {% for product in products %}
            <article class="border border-gray-200 rounded-lg p-4 shadow-sm">
                <img src="{{ product.imageUrl }}" alt="{{ product.name }}" class="w-full h-48 object-cover rounded">

                <h3 style="font-size: 18px; font-weight: 600; margin-top: 12px;">
                    {{ product.name }}
                </h3>

                <p style="color: #6b7280; font-size: 14px; margin-top: 4px;">
                    {{ product.description|slice(0, 80) }}…
                </p>

                {# Price formatting duplicated across 6 other templates #}
                <p style="font-size: 20px; font-weight: 700; color: #2563eb; margin-top: 8px;">
                    ${{ (product.priceCents / 100)|number_format(2) }}
                </p>

                {% if product.stock > 0 %}
                    <span style="background: #dcfce7; color: #166534; padding: 2px 8px; border-radius: 9999px; font-size: 12px;">
                        In stock
                    </span>
                {% else %}
                    <span style="background: #fee2e2; color: #991b1b; padding: 2px 8px; border-radius: 9999px; font-size: 12px;">
                        Out of stock
                    </span>
                {% endif %}

                {# The "primary button", hand-written for the 14th time #}
                <button
                    onclick="fetch('/api/cart/add', { method: 'POST', body: JSON.stringify({ id: {{ product.id }} }) }).then(() => location.reload())"
                    style="background: #2563eb; color: white; padding: 8px 16px; border-radius: 6px; border: none; width: 100%; margin-top: 16px; cursor: pointer;"
                    {% if product.stock == 0 %}disabled style="opacity: 0.5"{% endif %}
                >
                    Add to cart
                </button>
            </article>
        {% endfor %}
    </div>
</section>

このテンプレートが脆弱ぜいじゃくな理由りゆう

これは一応いちおう動うごく。グリッドを描画びょうがし、在庫ざいこ状態じょうたいを扱あつかい、カートに追加ついかもできる。本番ほんばん環境かんきょうのこのページを見みたデザイナーは、特とくに文句もんくを言いわないだろう。

しかしこれは、5つの独立どくりつした理由りゆうから、複利ふくりで膨ふくらんでいく負債ふさいでもある。

1. 唯一ゆいいつの正解せいかいとなる情報じょうほう源げんがない

青あおの#2563eb、角丸かどまるの6px、余白よはくの8px 16pxは、ここと他たの13個このファイルにべた書がきされている。「プライマリボタン」の定義ていぎがどこにも存在そんざいしない。変更へんこうするには全文ぜんぶん検索けんさく・置換ちかんをするしかなく、必かならずどこかで見落みおとして、静しずかな見みた目めのずれが生うまれる。

その結果けっかは測定そくてい可能かのうだ。Figmaのモックアップと本番ほんばんの乖離かいりはスプリントを重かさねるごとに広ひろがり、やがて誰だれもどちらも信用しんようしなくなる。

2. 表示ひょうじロジックの重複じゅうふく

価格かかくのフォーマット(priceCents / 100、桁けた区切くぎり、通貨つうか記号きごう)は、価格かかくが表示ひょうじされる場所ばしょすべてで繰くり返かえされている。多た通貨つうか対応たいおうや税込ぜいこみ表示ひょうじを追加ついかする日ひには、すべての出現しゅつげん箇所かしょを探さがし出だす必要ひつようがある。これはビジネスロジックではなく表示ひょうじロジックであり、同おなじだけの配慮はいりょに値あたいする。

3. 単体たんたいでテストも文書ぶんしょ化かもできない描画びょうが

無効むこう化かされたボタンの見みた目めを確認かくにんするには、アプリケーションを起動きどうし、ログインし、カタログまで移動いどうし、在庫ざいこ切ぎれの商品しょうひんを探さがす必要ひつようがある。ボタンだけを、その6通とおりのバリエーションを、1秒びょうで描画びょうがする方法ほうほうは存在そんざいしない。

結果けっかとして、レアケース(エラー、ローディング、非常ひじょうに長ながいテキスト、空そらリスト)は本番ほんばんで爆発ばくはつするまで誰だれの目めにも触ふれない。

4. ビジネスロジックと通信つうしんへの視覚しかくコンポーネントの結合けつごう

このボタンは/api/cart/addというURLを知しっていて、JSONペイロードの形かたちも把握はあくし、ページをリロードすることまで決きめている。視覚しかく的てきなコンポーネントがネットワークの責務せきむを背負せおってしまっている。このボタンをカートごと引ひきずらずに他たの場所ばしょで再さい利用りようすることは不可能ふかのうだ。

5. デザインと開発かいはつの間まに共通きょうつう言語げんごがない

デザイナーは「商品しょうひんカード」や「ステータスチップ」という言葉ことばを使つかう。コードが知しっているのはtemplates/catalog/list.html.twigだけだ。この共有きょうゆう語彙ごいの欠如けつじょが、デザインレビューのたびに翻訳ほんやく作業さぎょうを発生はっせいさせる。

Note

この5つの症状しょうじょうは、サーバー側がわのモノリシックなコントローラーで批判ひはんされるものと、まったく同おなじものがUI側がわに現あらわれているにすぎない。混在こんざいした責務せきむ、重複じゅうふく、単体たんたいテストの不可能ふかのう性せい。ヘキサゴナルアーキテクチャを知しっている人ひとなら、同おなじ既視感きしかんを覚おぼえるはずだ。


2. Atomic Designとは何なにか

目指めざすのは、ページを設計せっけいすることをやめて、システムを設計せっけいし始はじめることだ。ページは設計せっけいの単位たんいであることをやめ、より小ちいさなコンポーネントを組くみ立たてた結果けっかになる。そのコンポーネントもまた、さらに小ちいさなコンポーネントから組くみ立たてられている。

Brad Frostは化学かがくのメタファーを借かりている。物質ぶっしつは原子げんし(atom)でできていて、原子げんしは結合けつごうして分子ぶんし(molecule)になり、分子ぶんしは有機ゆうき体たい(organism)を形作かたちづくる。どのレベルも恣意しい的てきなものではない。それぞれが複雑ふくざつさと具体ぐたい性せいの異ことなる度合どあいを表あらわしている。

5つのレベル

1. アトム(原子げんし)

ボタン、入力にゅうりょく欄らん、ラベル、アイコン、見出みだしといった、それ以上いじょう分割ぶんかつできない構成こうせい要素ようそ。アトム単体たんたいには機能きのう的てきな意味いみがない(ラベルのない入力にゅうりょく欄らんは役やくに立たたない)が、それでもプロダクトの視覚しかく的てきアイデンティティをまるごと担になっている。

  • ビジネスロジックを一切いっさい含ふくまない。
  • APIも、ストアも、現在げんざいのルートも知しらない。
  • 完全かんぜんにpropsや属性ぞくせいによって駆動くどうされる。

2. 分子ぶんし(Molecule)

分子ぶんしは、ひとつのまとまったタスクを成なし遂とげるためにアトムを組くみ合あわせたものだ。ラベル+入力にゅうりょく欄らん+エラーメッセージでFormFieldになる。入力にゅうりょく欄らん+ボタンでSearchFieldになる。

これはインターフェースが初はじめて*使つかえる*ものになるレベルだ。分子ぶんしはローカルなUI状態じょうたい(開閉かいへい、ホバー)を持もつことはあっても、ビジネスロジックはまだ持もたない。

3. 有ゆう機体きたい(Organism)

有ゆう機体きたいは、比較的ひかくてき複雑ふくざつで自己じこ完結かんけつしたインターフェースの一いち区画くかくだ。サイトヘッダー、商品しょうひんカード、結果けっか一覧いちらんグリッド、完成かんせいしたフォームなど。分子ぶんしとアトムを組くみ合あわせて構成こうせいされる。

**ビジネス語彙ごい**が正当せいとうに登場とうじょうし始はじめるのはここからだ。有ゆう機体きたいはProductCardと名付なづけられ、Productオブジェクトを受うけ取とることができる。プロダクト固有こゆうではあるが、ページをまたいで再さい利用りよう可能かのうな状態じょうたいは保たもっている。

4. テンプレート(Template)

テンプレートはページの骨組ほねぐみであり、実じつデータを持もたずにレイアウトと有機ゆうき体たいの配置はいちを定義ていぎする。ワイヤーフレームのコード版ばんと言いっていい。

その役割やくわりは、コンテンツとは独立どくりつに、構造こうぞう・密度みつど・レスポンシブ挙動きょどうを検証けんしょうすることだ。

5. ページ(Page)

ページはテンプレートの具体ぐたい的てきなインスタンスであり、実じつデータによって満みたされる。外そとの世界せかいと接続せつぞくする唯一ゆいいつのレベルだ。ルーティング、データ取得しゅとく、SEOメタデータ、グローバルな状態じょうたい。

システムの堅牢けんろう性せいが試ためされるのもこのレベルだ。商品しょうひん名めいが200文字もじだったら?リストが空そらだったら?画像がぞうが見みつからなかったら?


依存いぞんは下しも方向ほうこうにしか向むかないという法則ほうそく

ヘキサゴナルアーキテクチャは依存いぞん性せい逆転ぎゃくてんの原則げんそくの上うえに成なり立たっている。Atomic Designも同おなじくらい短みじかく、同おなじくらい頻繁ひんぱんに破やぶられるルールの上うえに成なり立たっている。

Important

コンポーネントは厳密げんみつに下位かいのレベルのコンポーネントしか組くみ合あわせてはならず、自分じぶんがどこで使つかわれるかを一切いっさい知しってはならない。

ここから実務じつむ上じょうの帰結きけつが2つ導みちびかれ、それこそがこのモデルの価値かちのすべてだと言いっていい。

1. 依存いぞんは下しも方向ほうこうにしか向むかない。 アトムはどの分子ぶんしも知しらない。分子ぶんしはどの有機ゆうき体たいも知しらない。有ゆう機体きたいがページをインポートすることはない。このルールは静的せいてきに検証けんしょう可能かのうであり、ヘキサゴンにおけるレイヤールールとまったく同おなじだ(自動じどう化かの方法ほうほうは後述こうじゅつする)。

2. 下したに行いくほど純度じゅんどが上あがる。 階層かいそうの下したにいるコンポーネントほど、より汎用はんよう的てきで、安定あんていしていて、再さい利用りようしやすい。上うえにいくほど、より特化とっかしていて、揮発きはつ性せいが高たかく、外部がいぶと接続せつぞくしている。

レベルビジネスロジックデータアクセス再さい利用りよう性せい変更へんこう頻度ひんど
アトム❌ なし❌ なし万能ばんのう極きわめて稀まれ
分子ぶんし❌ なし❌ なし高たかい稀まれ
有ゆう機体きたい⚠️ 表示ひょうじレベルのみ⚠️ できればpropsで中ちゅう程度ていど定期ていき的てき
テンプレート❌ なし❌ 仮かりデータのみ低ひくい定期ていき的てき
ページ✅ オーケストレーション✅ ありなし頻繁ひんぱん

これはヘキサゴンとまったく同おなじ動うごきだ。安定あんていしているものを、揮発きはつ性せいのあるものから切きり離はなす。 アプリケーションコアがビジネスルールを技術ぎじゅつ的てきな詳細しょうさいから守まもるように、アトムはページの気きまぐれから視覚しかく的てきアイデンティティを守まもる。

Note

Brad Frostは、忘わすれられがちな点てんを強調きょうちょうしている。Atomic Designは**直線ちょくせん的てきなプロセスではない**。まずすべてのアトムを設計せっけいし、次つぎにすべての分子ぶんしを設計せっけいする、というものではない。ページのモックアップから出発しゅっぱつしてコンポーネントを抽出ちゅうしゅつするなど、レベル間かんを絶たえず行いき来きする。このモデルはレンズであって、順序じゅんじょ立たった方法ほうほう論ろんではない。

以降いこうの記事きじでは、このスパゲッティ状じょうのテンプレートをシステムへと作つくり直なおし、両方りょうほうの技術ぎじゅつで各かくレベルを並行へいこうして構築こうちくしていく。


3. レベルゼロ:デザイントークン

アトムより前まえに、アトムが何なんでできているかを決きめておく必要ひつようがある。#2563ebをべた書がきした青あおいボタンはアトムではなく、姿すがたを変かえたマジックナンバーにすぎない。

デザイントークンとは、色いろ・余白よはく・タイポグラフィ・角丸かどまる・影かげに名前なまえを付つけた値ねのことだ。デザインとコードの間まの契約けいやくと言いえる。

素もとのCSSで、TwigからもVueからも使つかえる

/* assets/styles/tokens.css */
:root {
    /* Semantic colors — never a raw color name inside components */
    --color-brand: #2563eb;
    --color-brand-hover: #1d4ed8;
    --color-surface: #ffffff;
    --color-text: #1a1a1a;
    --color-text-muted: #6b7280;
    --color-success-bg: #dcfce7;
    --color-success-text: #166534;
    --color-danger-bg: #fee2e2;
    --color-danger-text: #991b1b;

    /* Spacing scale — no arbitrary values */
    --space-1: 0.25rem;
    --space-2: 0.5rem;
    --space-3: 0.75rem;
    --space-4: 1rem;
    --space-6: 1.5rem;

    /* Typography */
    --font-size-sm: 0.875rem;
    --font-size-base: 1rem;
    --font-size-lg: 1.125rem;
    --font-size-xl: 1.5rem;

    /* Shapes */
    --radius-md: 0.375rem;
    --radius-full: 9999px;
}

[data-theme="dark"] {
    --color-surface: #111827;
    --color-text: #f9fafb;
    --color-text-muted: #9ca3af;
}

Vue側がわでは、UnoCSSで

// unocss.config.ts
import { defineConfig } from 'unocss'

export default defineConfig({
  theme: {
    colors: {
      brand: {
        DEFAULT: 'var(--color-brand)',
        hover: 'var(--color-brand-hover)',
      },
      surface: 'var(--color-surface)',
    },
  },
})

Tip

良よいトークンシステムの決定的けっていてきなテストがある。コンポーネントのフォルダ内ないで#を検索けんさくして、何なにもヒットしないこと。アトムの中なかに見みつかったリテラルな色いろは、まだ名前なまえを付つけられていないトークンだ。書かくのは些細ささいだが、驚おどろくほど効果こうか的てきなlintルールになる。

一ひとつ大事だいじなニュアンスとして、トークンには**役割やくわり**で名前なまえを付つけること(--color-danger-bg)。見みた目めで名前なまえを付つけてはいけない(--color-red-100)。そうしないと、赤あかがオレンジになった日ひに、redという名前なまえのトークンが#f97316を保持ほじしているという事態じたいになる。


4. 実践じっせん編へん:アトム

いよいよリファクタリングだ。14回かい書かき直なおされたあのプライマリボタンを、ひとつのアトムにする。

アトムを設計せっけいする際さいのルール

アトムは見みた目めと状態じょうたいを記述きじゅつするプロパティしか公開こうかいしてはならず、自分じぶんの使つかわれる文脈ぶんみゃくを公開こうかいしてはならない。消費しょうひするのはデザイントークンだけであり、それ以外いがいは何なにもない。そして能動のうどう的てきに何なにかをするのではなく、イベントを発行はっこうする。addToCartではなくclickだ。

アトムは外部がいぶマージンを一切いっさい持もたず、ストアにもルートにもAPIにも触ふれず、ビジネス由来ゆらいの名前なまえも持もたない。CheckoutButtonは悪わるいアトム名めいの典型てんけいだ。

Important

外部がいぶマージン禁止きんしのルールは、最もっとも頻繁ひんぱんに破やぶられ、最もっともコストがかかるものだ。margin-bottom: 16pxを宣言せんげんしたアトムは、そのレイアウトをすべての親おやに押おし付つけることになる。水平すいへい方向ほうこうのツールバーに配置はいちした日ひには、margin-bottom: 0 !importantとの戦たたかいが始はじまる。アトムに属ぞくするのは*内ない側がわのpaddingであり、要素ようそ間かん*の余白よはくはコンテナ側がわの責任せきにんであって、理想りそう的てきにはgapで表現ひょうげんする。

Symfony側がわ:無名むめいのTwigコンポーネント

Symfony UX Twig Componentsでは、ロジックさえなければPHPクラスを一切いっさい書かかずにコンポーネントを宣言せんげんできる。それはまさにアトムの状況じょうきょうそのものだ。

{# templates/components/Atom/Button.html.twig #}
{% props variant = 'primary', size = 'md', type = 'button', disabled = false %}

{% set variants = {
    primary:   'bg-brand text-white hover:bg-brand-hover',
    secondary: 'bg-transparent text-brand border border-brand hover:bg-brand/5',
    ghost:     'bg-transparent text-muted hover:bg-black/5',
} %}

{% set sizes = {
    sm: 'text-sm px-3 py-1.5',
    md: 'text-base px-4 py-2',
    lg: 'text-lg px-6 py-3',
} %}

<button
    type="{{ type }}"
    {{ disabled ? 'disabled' : '' }}
    {{ attributes.defaults({
        class: 'inline-flex items-center justify-center gap-2 rounded-md font-medium
                transition-colors disabled:opacity-50 disabled:cursor-not-allowed
                focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand
                ' ~ variants[variant] ~ ' ' ~ sizes[size]
    }) }}
>
    {% block content %}{% endblock %}
</button>

使つかい方かた:

<twig:Atom:Button variant="secondary" size="sm">Cancel</twig:Atom:Button>
<twig:Atom:Button type="submit">Confirm</twig:Atom:Button>

{{ attributes.defaults({...}) }}に注目ちゅうもくしてほしい。これのおかげで、呼よび出だし側がわはdata-*、aria-*、Stimulusの属性ぞくせいなどを、アトム側がわがその存在そんざいを知しることなく渡わたせる。これがなければ、新あたらしい要件ようけんが出でるたびにアトムへpropを追加ついかすることになる。

Vue 3:同おなじアトムをSFCとして

<!-- src/components/atoms/AButton.vue -->
<script setup lang="ts">
interface Props {
  variant?: 'primary' | 'secondary' | 'ghost'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
}

const { variant = 'primary', size = 'md', disabled = false } = defineProps<Props>()

const variants = {
  primary: 'bg-brand text-white hover:bg-brand-hover',
  secondary: 'bg-transparent text-brand border border-brand hover:bg-brand/5',
  ghost: 'bg-transparent text-muted hover:bg-black/5',
} as const

const sizes = {
  sm: 'text-sm px-3 py-1.5',
  md: 'text-base px-4 py-2',
  lg: 'text-lg px-6 py-3',
} as const
</script>

<template>
  <button
    :disabled="disabled"
    class="inline-flex items-center justify-center gap-2 rounded-md font-medium
           transition-colors disabled:opacity-50 disabled:cursor-not-allowed
           focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
    :class="[variants[variant], sizes[size]]"
  >
    <slot />
  </button>
</template>

使つかい方かた:

<AButton variant="secondary" size="sm">Cancel</AButton>
<AButton @click="submit">Confirm</AButton>

Note

2つの実装じっそうは構造こうぞう的てきにまったく同一どういつだ。propsも同おなじ、バリアントも同おなじ、クラスも同おなじ、スロットも同おなじ。違ちがうのは構文こうぶんだけ。Atomic Designが記述きじゅつしているのはアーキテクチャであってテクノロジーではない。だからこそ、TwigからVueへ移行いこうするチームは、システム全体ぜんたいを考かんがえ直なおすことなく、コンポーネント単位たんいで移行いこうできる。

2つ目めのアトム:バッジ

{# templates/components/Atom/Badge.html.twig #}
{% props tone = 'neutral' %}

{% set tones = {
    neutral: 'bg-gray-100 text-gray-700',
    success: 'bg-success-bg text-success-text',
    danger:  'bg-danger-bg text-danger-text',
} %}

<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium {{ tones[tone] }}">
    {% block content %}{% endblock %}
</span>
<!-- src/components/atoms/ABadge.vue -->
<script setup lang="ts">
const { tone = 'neutral' } = defineProps<{
  tone?: 'neutral' | 'success' | 'danger'
}>()

const tones = {
  neutral: 'bg-gray-100 text-gray-700',
  success: 'bg-success-bg text-success-text',
  danger: 'bg-danger-bg text-danger-text',
} as const
</script>

<template>
  <span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium" :class="tones[tone]">
    <slot />
  </span>
</template>

命名めいめいに注目ちゅうもくしてほしい。tone="danger"であってcolor="red"ではない。アトムが公開こうかいしているのは意図いとであって、視覚しかく的てきな値ねではない。デザインが「danger」をオレンジにすると決きめた日ひ、呼よび出だし側がわは何なにも変かえる必要ひつようがない。


5. 分子ぶんし:ひとつのタスクのために組くみ立たてる

分子ぶんしはアトムを組くみ合あわせて、ひとつのことを成なし遂とげる。これは分子ぶんしと有機ゆうき体たいを見分みわける最もっとも信頼しんらいできるテストでもある。「〜と〜」と言いわずにその役割やくわりを一文いちぶんで説明せつめいできないなら、それはおそらく有ゆう機体きたいだ。

StockBadge:生なまデータから視覚しかく的てきな意図いとへ

legacyなテンプレートには、在庫ざいこに関かんするif/elseがあちこちに重複じゅうふくしていた。これはまさに分子ぶんしだ。データを視覚しかく表現ひょうげんに変換へんかんする。

{# templates/components/Molecule/StockBadge.html.twig #}
{% props stock %}

{% if stock > 10 %}
    <twig:Atom:Badge tone="success">In stock</twig:Atom:Badge>
{% elseif stock > 0 %}
    <twig:Atom:Badge tone="neutral">Only {{ stock }} left</twig:Atom:Badge>
{% else %}
    <twig:Atom:Badge tone="danger">Out of stock</twig:Atom:Badge>
{% endif %}
<!-- src/components/molecules/MStockBadge.vue -->
<script setup lang="ts">
const { stock } = defineProps<{ stock: number }>()
</script>

<template>
  <ABadge v-if="stock > 10" tone="success">In stock</ABadge>
  <ABadge v-else-if="stock > 0" tone="neutral">Only {{ stock }} left</ABadge>
  <ABadge v-else tone="danger">Out of stock</ABadge>
</template>

Tip

> 10というしきい値ちは、分子ぶんしに紛まぎれ込こんでしまったビジネスルールだ。厳密げんみつに言いえば、この計算けいさんはドメイン側がわに属ぞくするべきであり、分子ぶんしはすでに決定けってい済ずみのステータス(status: 'in_stock' | 'low' | 'out')を受うけ取とるべきだ。これはよくある現実げんじつ的てきな妥協だきょう点てんだ。表示ひょうじルールが些細ささいなものであれば許容きょようできるが、しきい値ねが設定せってい可能かのうになったり顧客こきゃく依存いぞんになったりした瞬間しゅんかんに拒否きょひすべきものになる。

PriceTag:フォーマットを一いち箇所かしょに集約しゅうやくする

{# templates/components/Molecule/PriceTag.html.twig #}
{% props amountCents, currency = 'USD', size = 'md' %}

{% set sizes = { sm: 'text-sm', md: 'text-xl', lg: 'text-3xl' } %}

<p class="font-bold text-brand {{ sizes[size] }}">
    {{ (amountCents / 100)|format_currency(currency) }}
</p>
<!-- src/components/molecules/MPriceTag.vue -->
<script setup lang="ts">
const { amountCents, currency = 'USD', size = 'md' } = defineProps<{
  amountCents: number
  currency?: string
  size?: 'sm' | 'md' | 'lg'
}>()

const sizes = { sm: 'text-sm', md: 'text-xl', lg: 'text-3xl' } as const

const formatted = computed(() =>
  new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amountCents / 100),
)
</script>

<template>
  <p class="font-bold text-brand" :class="sizes[size]">{{ formatted }}</p>
</template>

通貨つうかのフォーマットは、スタックごとにちょうど1箇所かしょだけに存在そんざいするようになった。通貨つうかを追加ついかする、ロケールを変かえる、「税抜ぜいぬき/税込ぜいこみ」を表示ひょうじする、いずれも1ファイルで完結かんけつする。

FormField:教科書きょうかしょ的てきな事例じれい

<!-- src/components/molecules/MFormField.vue -->
<script setup lang="ts">
const { label, error, hint, required = false } = defineProps<{
  label: string
  error?: string
  hint?: string
  required?: boolean
}>()

const id = useId()
const describedBy = computed(() =>
  [error && `${id}-error`, hint && `${id}-hint`].filter(Boolean).join(' ') || undefined,
)
</script>

<template>
  <div class="flex flex-col gap-1">
    <ALabel :for="id" :required="required">{{ label }}</ALabel>

    <slot :id="id" :described-by="describedBy" :invalid="!!error" />

    <p v-if="hint && !error" :id="`${id}-hint`" class="text-sm text-muted">
      {{ hint }}
    </p>
    <p v-if="error" :id="`${id}-error`" class="text-sm text-danger-text" role="alert">
      {{ error }}
    </p>
  </div>
</template>

この分子ぶんしは、このレベルでしか担になえない責務せきむを持もっている。関係かんけい性せいとしてのアクセシビリティだ。ラベルとフィールドの結むすびつき(for/id)、フィールドとエラーメッセージの結むすびつき(aria-describedby)は、組くみ立たて時じにしか存在そんざいし得えない。どのアトムも単独たんどくでこれを扱あつかうことはできない。

これはこのモデル全体ぜんたいにとって過小かしょう評価ひょうかされている論点ろんてんだ。すべてのページが手作業てさぎょうでフィールドを組くみ立たてて直なおすシステムにおいて、正ただしいアクセシビリティを保証ほしょうすることは構造こうぞう的てきに不可能ふかのうだ。分子ぶんしに集約しゅうやくすれば、一度いちど手てに入いれればそれで済すむ。


6. 有ゆう機体きたい:ビジネスの登場とうじょう

有ゆう機体きたいはインターフェースの自己じこ完結かんけつした一いち区画くかくだ。ビジネスデータの形かたちを知しることが許ゆるされる最初さいしょのレベルである。

ProductCard

{# templates/components/Organism/ProductCard.html.twig #}
{% props product %}

<article class="flex flex-col gap-3 rounded-lg border border-gray-200 bg-surface p-4 shadow-sm">
    <img
        src="{{ product.imageUrl }}"
        alt="{{ product.name }}"
        loading="lazy"
        class="h-48 w-full rounded object-cover"
    >

    <div class="flex items-start justify-between gap-2">
        <h3 class="text-lg font-semibold">{{ product.name }}</h3>
        <twig:Molecule:StockBadge :stock="product.stock" />
    </div>

    <p class="text-sm text-muted">{{ product.description|u.truncate(80, '…') }}</p>

    <twig:Molecule:PriceTag :amountCents="product.priceCents" />

    <twig:Atom:Button
        class="mt-auto w-full"
        :disabled="product.stock == 0"
        data-action="cart#add"
        data-cart-product-id-param="{{ product.id }}"
    >
        Add to cart
    </twig:Atom:Button>
</article>
<!-- src/components/organisms/OProductCard.vue -->
<script setup lang="ts">
import type { Product } from '~/types/catalog'

const { product } = defineProps<{ product: Product }>()
const emit = defineEmits<{ addToCart: [productId: string] }>()
</script>

<template>
  <article class="flex flex-col gap-3 rounded-lg border border-gray-200 bg-surface p-4 shadow-sm">
    <img
      :src="product.imageUrl"
      :alt="product.name"
      loading="lazy"
      class="h-48 w-full rounded object-cover"
    >

    <div class="flex items-start justify-between gap-2">
      <h3 class="text-lg font-semibold">{{ product.name }}</h3>
      <MStockBadge :stock="product.stock" />
    </div>

    <p class="text-sm text-muted line-clamp-2">{{ product.description }}</p>

    <MPriceTag :amount-cents="product.priceCents" />

    <AButton
      class="mt-auto w-full"
      :disabled="product.stock === 0"
      @click="emit('addToCart', product.id)"
    >
      Add to cart
    </AButton>
  </article>
</template>

ここで立たち止どまる価値かちのある点てんが2つある。

有ゆう機体きたいはアクションを実行じっこうするのではなく、シグナルとして知しらせるだけだ。Vue側がわではaddToCartをemitし、Twig側がわではStimulusコントローラーに属性ぞくせい経由けいゆで処理しょりを委譲いじょうする。どちらの場合ばあいも、有ゆう機体きたいは/api/cart/addが存在そんざいすることすら知しらない。そのおかげで、バックエンドが一切いっさい動うごいていないドキュメントやテストやモックアップの中なかでも描画びょうが可能かのうな状態じょうたいを保たもっている。これはヘキサゴンにおける依存いぞん性せい逆転ぎゃくてんが、インターフェース側がわに移うつってきたものだ。コンポーネントは必要ひつようとするものを宣言せんげんし、呼よび出だし側がわがその実装じっそうを供給きょうきゅうする。

このファイル内ないで唯一ゆいいつのmarginはボタンのmt-autoだが、これは正当せいとうなものだ。カード自身じしんが親おやとして、自分じぶんのボタンを下端かたんに押おしやると決きめているからだ。外部がいぶマージン禁止きんしのルールが規定きていしているのは、コンポーネントと*その親おや*との関係かんけいであって、自分じぶん自身じしんの境界きょうかいの内側うちがわで起おきることではない。

ProductGrid

<!-- src/components/organisms/OProductGrid.vue -->
<script setup lang="ts">
import type { Product } from '~/types/catalog'

const { products, loading = false } = defineProps<{
  products: Product[]
  loading?: boolean
}>()
defineEmits<{ addToCart: [productId: string] }>()
</script>

<template>
  <div v-if="loading" class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
    <MCardSkeleton v-for="i in 6" :key="i" />
  </div>

  <MEmptyState
    v-else-if="products.length === 0"
    title="No products"
    description="Try widening your search criteria."
  />

  <div v-else class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
    <OProductCard
      v-for="product in products"
      :key="product.id"
      :product="product"
      @add-to-cart="$emit('addToCart', $event)"
    />
  </div>
</template>

この有機ゆうき体たいは、ページ側がわで繰くり返かえすべきではないものを担になっている。コレクションの3つの状態じょうたい、ローディング、空そら、データありだ。legacyなコードでは、空そら状態じょうたいとローディング状態じょうたいはそもそも存在そんざいしなかった。単たんに白紙はくしのページとして現あらわれるだけだった。有ゆう機体きたいに組くみ込こまれることで、忘わすれることが構造こうぞう的てきに不可能ふかのうになる。


7. テンプレートとページ:まず構造こうぞう、それからデータ

テンプレート:コンテンツなしのレイアウト

<!-- src/components/templates/TCatalogLayout.vue -->
<template>
  <div class="mx-auto grid max-w-7xl gap-8 px-6 py-8 lg:grid-cols-[16rem_1fr]">
    <aside class="hidden lg:block">
      <slot name="filters" />
    </aside>

    <main class="flex flex-col gap-6">
      <header class="flex flex-wrap items-center justify-between gap-4">
        <slot name="title" />
        <slot name="toolbar" />
      </header>

      <slot name="results" />

      <footer class="flex justify-center">
        <slot name="pagination" />
      </footer>
    </main>
  </div>
</template>

このファイルにはデータも、インポートも、ロジックも一切いっさい含ふくまれていない。区画くかくとそのレスポンシブな挙動きょどうを記述きじゅつするだけだ。最初さいしょの有ゆう機体きたいすら存在そんざいしない段階だんかいで、灰色はいいろのブロックでこれを検証けんしょうできる。

Twig版ばんは、言語げんごがすでに用意よういしているブロック機能きのうを使つかう。

{# templates/components/Template/CatalogLayout.html.twig #}
<div class="mx-auto grid max-w-7xl gap-8 px-6 py-8 lg:grid-cols-[16rem_1fr]">
    <aside class="hidden lg:block">
        {% block filters %}{% endblock %}
    </aside>

    <main class="flex flex-col gap-6">
        <header class="flex flex-wrap items-center justify-between gap-4">
            {% block title %}{% endblock %}
            {% block toolbar %}{% endblock %}
        </header>

        {% block results %}{% endblock %}

        <footer class="flex justify-center">
            {% block pagination %}{% endblock %}
        </footer>
    </main>
</div>

ページ:外そとの世界せかいとの唯一ゆいいつの接点せってん

<!-- pages/catalog.vue -->
<script setup lang="ts">
const { products, loading, filters } = useCatalog()
const cart = useCartStore()

useHead({ title: 'Catalog — Our products' })
</script>

<template>
  <TCatalogLayout>
    <template #filters>
      <OFilterPanel v-model="filters" />
    </template>

    <template #title>
      <AHeading level="1">Our products</AHeading>
    </template>

    <template #toolbar>
      <MSortSelect v-model="filters.sort" />
    </template>

    <template #results>
      <OProductGrid
        :products="products"
        :loading="loading"
        @add-to-cart="cart.add"
      />
    </template>

    <template #pagination>
      <MPagination v-model="filters.page" :total="products.length" />
    </template>
  </TCatalogLayout>
</template>

ページは配線はいせんファイルになった。CSSクラスも、ifも、フォーマット処理しょりも、もう一ひとつもない。インフラのコントローラーがHTTPリクエストをユースケースに接続せつぞくするのとまったく同おなじように、実じつデータを既存きそんの構造こうぞうにつなぎ込こむだけだ。

第だい1章しょうのテンプレートと比くらべてみてほしい。インラインスタイル、フォーマット、条件じょうけん分岐ぶんき、ネットワーク呼よび出だしにまみれた45行こうが、一目いちもくで読よめる宣言せんげんに変かわった。


8. ディレクトリ構成こうせいと命名めいめい規則きそく

ディレクトリ構成こうせい

Symfony側がわ

templates/
├── components/
│   ├── Atom/
│   │   ├── Button.html.twig
│   │   ├── Badge.html.twig
│   │   ├── Input.html.twig
│   │   └── Label.html.twig
│   ├── Molecule/
│   │   ├── StockBadge.html.twig
│   │   ├── PriceTag.html.twig
│   │   └── FormField.html.twig
│   ├── Organism/
│   │   ├── ProductCard.html.twig
│   │   └── SiteHeader.html.twig
│   └── Template/
│       └── CatalogLayout.html.twig
└── pages/
    └── catalog/
        └── list.html.twig

src/Twig/Components/          <-- Only components that need logic
├── Molecule/
│   └── SearchField.php
└── Organism/
    └── CartSummary.php       <-- Live Component (server-side state)

Vue側がわ

src/components/
├── atoms/
│   ├── AButton.vue
│   ├── ABadge.vue
│   └── AInput.vue
├── molecules/
│   ├── MStockBadge.vue
│   ├── MPriceTag.vue
│   └── MFormField.vue
├── organisms/
│   ├── OProductCard.vue
│   └── OProductGrid.vue
└── templates/
    └── TCatalogLayout.vue

pages/
└── catalog.vue               <-- The "Page" level, handled by the router

一文字ひともじの接頭せっとう辞じ(A/M/O/T)は賛否さんぴの分わかれる慣習かんしゅうだ。利点りてんは、コンポーネントのレベルが、それを使つかう場所ばしょでファイルを開ひらかずとも一目いちもくでわかること。AButton.vueの中なかに<OProductCard>が書かかれていれば、レビューの場ばで違反いはんを目めで見みて発見はっけんできる。

欠点けってんは、レベルが変かわったコンポーネントをリネームすると、すべての呼よび出だし元もとに手てを入いれることになる点てんだ。しかしそれこそがまさに望のぞましいことでもある。レベルの変更へんこうは*アーキテクチャの変更へんこう*そのものであり、目めに見みえる形かたちになって然しかるべきだ。

命名めいめい規則きそく

レベル名前なまえの由来ゆらい良よい例れい悪わるい例れい
アトムその形かたちButton, Input, IconCheckoutButton, UserAvatar
分子ぶんしそのタスクSearchField, PriceTagProductThing, Wrapper
有ゆう機体きたいそのビジネス概念がいねんProductCard, SiteHeaderSection2, BigBox
テンプレートそのレイアウトCatalogLayout, ArticleLayoutPage1, MainTemplate

根底こんていにあるルールは、コンポーネントの名前なまえがその抽象ちゅうしょう度どのレベルを反映はんえいしていなければならないということだ。CheckoutButtonという名前なまえのアトムは、それが自分じぶんの文脈ぶんみゃくを知しっているという告白こくはくであり、つまり再さい利用りよう不可能ふかのうであり、つまりそれはアトムではないということになる。


9. さらに深ふかく

コンポーネントを単体たんたいでテストする

コンポーネントを文脈ぶんみゃくから切きり離はなすことで、第だい1章しょうでは不可能ふかのうだったことが可能かのうになる。アプリケーションを起動きどうせずにテストすることだ。

Vue側がわ:VitestとTesting Library

// src/components/molecules/MStockBadge.test.ts
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import MStockBadge from './MStockBadge.vue'

describe('mStockBadge', () => {
  it('signals availability above 10 units', () => {
    render(MStockBadge, { props: { stock: 42 } })
    expect(screen.getByText('In stock')).toBeTruthy()
  })

  it('warns on low stock', () => {
    render(MStockBadge, { props: { stock: 3 } })
    expect(screen.getByText('Only 3 left')).toBeTruthy()
  })

  it('signals depletion at zero', () => {
    render(MStockBadge, { props: { stock: 0 } })
    expect(screen.getByText('Out of stock')).toBeTruthy()
  })
})

Symfony側がわ:InteractsWithTwigComponents

Symfony UXは、コンポーネントをテスト内ないで単体たんたい描画びょうがするための専用せんようtraitを提供ていきょうしている。

<?php

declare(strict_types=1);

namespace App\Tests\Twig\Components;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\UX\TwigComponent\Test\InteractsWithTwigComponents;

final class ButtonTest extends KernelTestCase
{
    use InteractsWithTwigComponents;

    public function testRendersPrimaryVariantByDefault(): void
    {
        $rendered = $this->renderTwigComponent('Atom:Button', ['type' => 'submit']);

        self::assertStringContainsString('bg-brand', (string) $rendered);
        self::assertStringContainsString('type="submit"', (string) $rendered);
    }

    public function testDisabledStateIsExposedToAssistiveTechnology(): void
    {
        $rendered = $this->renderTwigComponent('Atom:Button', ['disabled' => true]);

        self::assertStringContainsString('disabled', (string) $rendered);
    }
}

Tip

こうしたテストは数すうミリ秒びょうで完了かんりょうし、データベースもブラウザも認証にんしょう済ずみセッションも必要ひつようとしない。50個このコンポーネントからなるシステムでも、フルスイートが3秒びょう未満みまんで終おわる。これこそが、恐おそれずにリファクタリングするために必要ひつようなフィードバックループだ。

ドキュメント化か:システムのショールーム

誰だれも参照さんしょうしないデザインシステムは、スプリントのたびに再さい発明はつめいされる。スタックによって2つのアプローチがある。

Vue側がわでは、Storybook(またはHistoire)が各かくコンポーネントをあらゆるバリエーションで描画びょうがしてくれる。

// src/components/atoms/AButton.stories.ts
import type { Meta, StoryObj } from '@storybook/vue3'
import AButton from './AButton.vue'

const meta = {
  title: 'Atoms/Button',
  component: AButton,
  argTypes: {
    variant: { control: 'select', options: ['primary', 'secondary', 'ghost'] },
    size: { control: 'select', options: ['sm', 'md', 'lg'] },
  },
} satisfies Meta<typeof AButton>

export default meta

export const Primary: StoryObj<typeof meta> = {
  args: { variant: 'primary' },
  render: args => ({
    components: { AButton },
    setup: () => ({ args }),
    template: '<AButton v-bind="args">Add to cart</AButton>',
  }),
}

export const Disabled: StoryObj<typeof meta> = { args: { disabled: true } }

Symfony側がわでは、Storybookを統合とうごうすること自体じたいは可能かのうだが、コストが重おもい。開発かいはつ環境かんきょう限定げんていのショールーム用ようルートを1本ほん用意よういするほうがはるかに安上やすあがりだ。

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class DesignSystemController extends AbstractController
{
    #[Route('/_design-system', name: 'design_system', env: 'dev')]
    public function index(): Response
    {
        return $this->render('design_system/index.html.twig');
    }
}

対応たいおうするテンプレートは、すべてのアトムをあらゆる組くみ合あわせで描画びょうがする。Storybookよりは貧弱ひんじゃくだが、構築こうちくには1時間じかんもかからず、ビルドの依存いぞんも増ふえず、必要ひつようの9割わりはこれでカバーできる。すべての状態じょうたいを一目いちもくで確認かくにんできることだ。

アーキテクチャを自動的じどうてきに強制きょうせいする

下しも方向ほうこう依存いぞんの法則ほうそくは、コードレビューだけが唯一ゆいいつのチェック手段しゅだんだと、納期のうきのプレッシャーの前まえでは生いき残のこれない。ヘキサゴンのときと同おなじで、CIでブロッキングにする必要ひつようがある。

TypeScript側がわ:eslint-plugin-boundaries

// eslint.config.js
import boundaries from 'eslint-plugin-boundaries'

export default [
  {
    plugins: { boundaries },
    settings: {
      'boundaries/elements': [
        { type: 'atoms', pattern: 'src/components/atoms/*' },
        { type: 'molecules', pattern: 'src/components/molecules/*' },
        { type: 'organisms', pattern: 'src/components/organisms/*' },
        { type: 'templates', pattern: 'src/components/templates/*' },
        { type: 'pages', pattern: 'pages/*' },
      ],
    },
    rules: {
      'boundaries/element-types': ['error', {
        default: 'disallow',
        rules: [
          // An atom composes nothing: it is terminal.
          { from: 'atoms', allow: [] },
          { from: 'molecules', allow: ['atoms'] },
          { from: 'organisms', allow: ['atoms', 'molecules'] },
          { from: 'templates', allow: [] },
          { from: 'pages', allow: ['atoms', 'molecules', 'organisms', 'templates'] },
        ],
      }],
    },
  },
]

アトムから有機ゆうき体たいをインポートしようとすると、これ以降いこうはlintが、つまりCIが失敗しっぱいするようになる。

PHP側がわ:Deptrac、ただし重要じゅうような注意ちゅうい点てんあり

DeptracはPHPの名前なまえ空間くうかんを対象たいしょうに判定はんていするため、クラスを持もつコンポーネントは完璧かんぺきにカバーできる。

# deptrac.yaml
deptrac:
  paths:
    - src/Twig/Components/
  layers:
    - name: Atom
      collectors:
        - { type: directory, value: src/Twig/Components/Atom/.* }
    - name: Molecule
      collectors:
        - { type: directory, value: src/Twig/Components/Molecule/.* }
    - name: Organism
      collectors:
        - { type: directory, value: src/Twig/Components/Organism/.* }
  ruleset:
    Atom: ~              # An atom depends on no other component
    Molecule:
      - Atom
    Organism:
      - Atom
      - Molecule

Warning

知しっておくべき制約せいやく: *無名むめい*のTwigコンポーネントにはPHPクラスが存在そんざいしない。その依存いぞん関係かんけいは.twigファイル内ないの<twig:Organism:ProductCard />というタグの中なかに存在そんざいするだけで、Deptracからは完全かんぜんに見みえない。そして、最もっとも守まもるべきアトムや分子ぶんしこそが、最もっとも無名むめいコンポーネントになりがちなのだ。

これを補おぎなう、些細ささいだが効果こうか的てきなチェックがこのギャップを埋うめる。

#!/usr/bin/env bash
# bin/check-atomic-boundaries.sh
set -euo pipefail

status=0

# An atom must not reference any higher-level component.
if grep -rlE '<twig:(Molecule|Organism|Template):' templates/components/Atom/ 2>/dev/null; then
    echo "❌ An atom composes a higher-level component." >&2
    status=1
fi

# A molecule must reference neither organisms nor templates.
if grep -rlE '<twig:(Organism|Template):' templates/components/Molecule/ 2>/dev/null; then
    echo "❌ A molecule composes a higher-level component." >&2
    status=1
fi

# No literal color may remain inside components.
if grep -rnE '#[0-9a-fA-F]{3,8}\b' templates/components/ 2>/dev/null; then
    echo "❌ Literal color detected: use a design token." >&2
    status=1
fi

exit $status

CIに組くみ込こまれた20行こうのシェルスクリプトは、誰だれもが知しっていて誰だれも守まもらない規約きやくより役やくに立たつ。

最もっともコストの高たかいアンチパターン

1. 分類ぶんるい学がく的てき麻痺まひ

症状しょうじょう:UserAvatarは分子ぶんしなのか有ゆう機体きたいなのか、チームが30分ふん議論ぎろんする。

これが最もっともありがちで、最もっとも不毛ふもうな罠わなだ。Brad Frost自身じしんが繰くり返かえし述のべている。分類ぶんるいはコミュニケーションのための道具どうぐであって、科学かがくではない。エスカレーション解除かいじょルールを採用さいようしよう。議論ぎろんが2分ふんを超こえたら、コンポーネントを上位じょういのレベルに置おいて先さきに進すすむ。分類ぶんるいを間違まちがえたコンポーネントの代償だいしょうはファイルの移動いどう一ひとつだが、毎週まいしゅうの分類ぶんるい会議かいぎはプロジェクトそのものの代償だいしょうになる。

2. 全知全能ぜんちぜんのうのアトム

<!-- ❌ Twenty-three boolean props: this button has absorbed every edge case -->
<AButton
  :is-loading="true" :is-icon-only="false" :is-full-width="true"
  :has-badge="true" :badge-count="3" :is-dropdown-trigger="false"
  :show-spinner-left="true" ...
/>

エッジケースが増ふえるたびにpropが追加ついかされ、ついにはアトムが読よめもテストもできないものになり、理論りろん上じょう2²³通どおりの組くみ合あわせが生うまれる。処方箋しょほうせんは設定せっていより構成こうせい、つまり少数しょうすうの意味いみのあるバリアントと、それ以外いがいはすべてスロットに任まかせることだ。

<!-- ✅ Variation goes through content, not props -->
<AButton variant="primary" size="lg" class="w-full">
  <ASpinner v-if="pending" />
  <IconCart v-else />
  Add to cart
</AButton>

3. 幽霊ゆうれい分子ぶんし

MButtonWrapper.vueというファイルがあり、その中身なかみは<AButton><slot /></AButton>だけ。これは何なにも足たしておらず、ナビゲーションに間接かんせつ参照さんしょうの階層かいそうを一ひとつ増ふやし、コンポーネントツリーを混乱こんらんさせるだけだ。構造こうぞうも、振ふる舞まいも、意味いみも追加ついかしないコンポーネントは、存在そんざいすべきではない。

4. レベルをまたぐprop drilling

currentUserをページから4つのレベルを経へてアトムまで渡わたすということは、分解ぶんかいの仕方しかたが間違まちがっているか、コンテキストの仕組しくみ(Vueのprovide/inject、Twigのグローバルコンテキスト変数へんすう)が欠かけているかのどちらかだ。現在げんざいのユーザーを知しる必要ひつようのあるアトムは、定義ていぎ上じょう、もはやアトムではない。

5. 早はやすぎるビジネス命名めいめい

atoms/の中なかに置おかれた<CheckoutSubmitButton>。この名前なまえ自体じたいが違反いはんを物語ものがたっている。このアトムはチェックアウトの流ながれを知しってしまっている。正ただしい形かたちは、汎用はんよう的てきな<AButton>であり、それをビジネス語彙ごいを正当せいとうに担になう<OCheckoutForm>という有機ゆうき体たいが使つかう、という構図こうずだ。

他たのアプローチとの関係かんけい

Atomic Designはいくつかの近きん縁えんのモデルと共存きょうぞんしており、どれがどの問といに答こたえているのかを知しっておくと役やくに立たつ。

Feature-Sliced Designは、抽象ちゅうしょう度どのレベルではなく*機能きのう*単位たんいでコードを整理せいりする。この2つは競合きょうごうしない。大だい規模きぼなアプリケーションでは、横断おうだん的てきなアトミックデザインシステム(FSDのshared/uiはまさにアトムと分子ぶんしそのものだ)の上うえにフィーチャー単位たんいの分割ぶんかつを重かさねる構成こうせいをよく見みかける。おそらく大だい規模きぼにおいて最もっとも堅牢けんろうな組くみ合あわせだろう。

ITCSSはCSS側がわで同おなじ直感ちょっかんに答こたえている。汎用はんようから特化とっかへと、特異とくい性せいが増ます順じゅんに整理せいりするという考かんがえ方かただ。UnoCSSやTailwindのようなアトミックなエンジンがあれば、この問といはほぼ意味いみを失うしなう。トークンとコンポーネントのバリアントが、カスケードに取とって代かわるからだ。

そして3レベル構成こうせいのシステムもある。多おおくの成熟せいじゅくしたチームは、モデルをprimitives / components / featuresに平坦へいたん化かし、片側かたがわでアトムと分子ぶんしを、もう片側かたがわで有ゆう機体きたいとテンプレートを統合とうごうしている。これは十分じゅうぶんに理りにかなっている。価値かちは下しも方向ほうこう依存いぞんの法則ほうそくそのものにあるのであって、階層かいそうの正確せいかくな段数だんすうにあるわけではない。30個このコンポーネントしかないプロジェクトで5段階だんかいのレベルを設もうけるのは、単たんなる儀式ぎしきにすぎない。


いつ採用さいようすべきか、いつ避さけるべきか

万能ばんのう薬やくになるアーキテクチャは存在そんざいしない。Atomic Designは実質じっしつ的てきなものを得える代かわりに、実質じっしつ的てきなコストを払はらう。

得えられるもの:ボタンの定義ていぎがひとつだけになり、見みた目めもひとつしかありえなくなる。最初さいしょは遅おそいが徐々じょじょに上あがっていく速度そくど。最初さいしょのページを作つくるのは時間じかんがかかるが、語彙ごいがすでにそろっている分ぶん、以降いこうのページはどんどん速はやくなる。デザイナーと開発かいはつ者しゃが同おなじものを同おなじ名前なまえで呼よぶようになり、誤解ごかいの層そうがまるごとなくなる。アプリを起動きどうせずに、あらゆる状態じょうたいを単体たんたいで描画びょうができるコンポーネント。そして、アクセシビリティが分子ぶんしの中なかで一いち度どだけ解決かいけつされる。ARIAの関係かんけい性せい、フォーカス管理かんり、状態じょうたい管理かんりを、ページごとに再さい発明はつめいする必要ひつようがなくなる。

払はらうコスト:最初さいしょのページが表示ひょうじされる前まえに、たとえ控ひかえめなシステムであっても数すう十じゅうファイルが必要ひつようになる。間接かんせつ参照さんしょうが増ふえる。ページがどう描画びょうがされるかを理解りかいするには、いまや4つ5つのファイルを開ひらく必要ひつようがあり、読よみ手てはモノリシックなテンプレートが与あたえてくれていた全体ぜんたい像ぞうを失うしなう。使つかわれることのないバリエーションを先回さきまわりして作つくりたくなる誘惑ゆうわくが常つねにつきまとう。そして継続けいぞく的てきな規律きりつが必要ひつようになる。自動じどう化かされた強制きょうせいがなければ、下しも方向ほうこう依存いぞんの法則ほうそくは数すうか月げつで崩くずれる。

視覚しかく的てきな語彙ごいを共有きょうゆうする画面がめんがたくさんあるアプリケーションで採用さいようしよう。ほとんどのSaaS、管理かんり画面がめん、ECサイトがこれに該当がいとうする。複数ふくすうのフロントエンド開発かいはつ者しゃ、あるいは複数ふくすうのチームが1つのプロダクトに関かかわるときに採用さいようしよう。デザインが継続けいぞく的てきなリデザインを重かさねながら、何なん年ねんも使つかわれ続つづけるプロダクトで採用さいようしよう。そして、視覚しかく的てきな一貫いっかん性せいが契約けいやく上じょう、あるいは規制きせい上じょうの要件ようけんになっている場所ばしょで採用さいようしよう。厳格げんかくなブランドガイドライン、あるいは操作そうさ性せいがリスク分析ぶんせきの一部いちぶとなる医療いりょう系けいソフトウェアなどだ。

数すうページしかないパンフレット的てきなサイトでは見送みおくろう。システムのコストがそれが支ささえるページ自体じたいのコストを上回うわまわってしまう。使つかい捨すてのプロトタイプでも見送みおくろう。速度そくどが優先ゆうせんされ、システムは後ごから抽出ちゅうしゅつすればいい。サードパーティのデザインシステムがすでに導入どうにゅうされているなら見送みおくろう。VuetifyやBootstrap、あるいは自社じしゃライブラリを使つかっているなら、アトムはすでに存在そんざいしているので、分子ぶんしレベルから始はじめればいい。サードパーティのコンポーネントの上うえに<AButton>を再さい構築こうちくするのは、単たんなる再さい抽象ちゅうしょう化かにすぎない。そして、リアルタイムダッシュボードの1画面がめんのような、極きわめて特化とっかした単一たんいつのインターフェースでも見送みおくろう。他たと共有きょうゆうできるものが何なにもないからだ。


まとめ

インターフェースを特異とくい性せいが増ましていくレベルへと階層かいそう化かし、依存いぞんを下しも方向ほうこうだけに向むけさせることで、4つのものを手てに入いれた。

プライマリボタンはいまや正確せいかくに1つだけ存在そんざいするので、角丸かどまるを変かえるということは、23個こではなく1個このファイルを編集へんしゅうすることを意味いみする。すべてのレベルが、データベースもブラウザもなしに、ミリ秒びょう単位たんいで単体たんたい描画びょうができる。同おなじシステムを、まったく同おなじ構造こうぞうでTwigにもVueにも実装じっそうできたということは、このモデルがフレームワークではなくアーキテクチャを記述きじゅつしているということの証あかしだ。そしてデザイナーと開発かいはつ者しゃは、ついに同おなじ名前なまえで同おなじものを指させるようになった。

歩あゆんできた道みちを振ふり返かえってみよう。

以前いぜん(モノリシックなページ)以後いご(Atomic Design)
ブランドカラーを変かえる23ファイルにわたる検索けんさく・置換ちかんトークン1つ
無効むこう化かされたボタンを見みるアプリを起動きどうし、在庫ざいこ切ぎれの商品しょうひんを探さがすストーリー1つ、1秒びょう
価格かかくのフォーマット7回かい重複じゅうふく分子ぶんし1つ
リストの空そら状態じょうたい存在そんざいしない(白紙はくしのページ)有ゆう機体きたいに組くみ込こみ済ずみ
フォームのアクセシビリティフィールドごとに毎回まいかい作つくり直なおしFormFieldで獲得かくとく済ずみ
似にたページを追加ついかする200行こうをコピペ有ゆう機体きたいを6個こ組くみ立たてる
アーキテクチャを検証けんしょうする目視もくしでのコードレビューCIでブロッキングされるlint

Atomic Designは、最初さいしょにより多おおくのファイルとより多おおくの規律きりつを要求ようきゅうする。その見返みかえりとして、コードベースの中なかで伝統でんとう的てきに最もっとも早はやく劣化れっかする資産しさんであるインターフェースが、価値かちが摩耗まもうするのではなく積つみ上あがっていくシステムに変かわる。

この論理ろんりがどこかで見覚みおぼえがあるとしたら、それは偶然ぐうぜんではない。ヘキサゴナルアーキテクチャとまったく同おなじ考かんがえ方かただ。安定あんていしているものを見極みきわめ、揮発きはつ性せいのあるものから切きり離はなし、依存いぞんを安定あんていした方向ほうこうへ向むかわせる。アトムがデザインにとって持もつ意味いみは、ドメインがビジネスにとって持もつ意味いみと同おなじである。

> share on linkedin
>