「データを3層に分ける考え方は分かった。では、dbtで実際にどう作ればよいのか」。設計の概念を理解したあと、いざ実装の段になって手が止まる方は少なくありません。層の意味づけや設計思想はメダリオンアーキテクチャ(Bronze/Silver/Gold)の解説に譲り、dbtでの具体的な作り方に絞ってお伝えします。フォルダ構成からsources定義、命名規則、層ごとのmaterializationとテストまでを順に見ていきます。

3層構造(Raw→Staging→Mart)のデータフロー俯瞰図

この記事で身につくこと

  • dbtプロジェクトのフォルダ構成と、各層に置くファイルの見分け方
  • Staging・Intermediate・Martそれぞれの「やること/やらないこと」
  • 層ごとのmaterializationとテストの選び方、初回セットアップの1週間ロードマップ

3層構造の早見表

まず全体像を1枚の表で押さえます。各章の詳細を読む前に、層ごとの役割・粒度・materialization・主なテストを俯瞰しておくと迷子になりません。

目的粒度命名materialization主なテスト
Raw / sourcesELで取り込んだ生テーブルの宣言(作らない)ソースのままYAMLで source('app','orders')freshness
Stagingリネーム・型変換・軽いフィルタ1ソース1モデルstg_<source>_<table>viewunique / not_null / relationships
Intermediate(任意)複数stagingをまたぐ再利用ロジック粗粒度の中間int_<topic>_<action>ephemeral / tablerow_count / expected_values
Martビジネスの質問に直接答える用途別の集計・結合fct_* / dim_*table or incrementalaccepted_values / dbt-utils各種

dbtプロジェクトのフォルダ構成

`models`配下を層ごとのフォルダに分けます。Raw(生データ)はdbtの`source`として外部に定義し、Staging・(必要なら)Intermediate・Martをモデルとして作ります。役割と置き場所を最初に決めておくと、チームのどのメンバーが見ても迷いません。

フォルダ / 定義役割命名例
sources(YAML)Raw層。取り込み済みの生テーブルを参照対象として宣言source(‘app’, ‘orders’)
models/staging/1ソース1モデルで素直に整えるstg_orders.sql
models/intermediate/複数stagingをまたぐ再利用ロジック(任意)int_orders_enriched.sql
models/marts/(ドメイン別)用途別の最終テーブルmarts/finance/fct_daily_sales.sql

入口:sourcesでRaw層を宣言する

Raw層はdbtで作るのではなく、すでに取り込まれた生テーブルを`sources`として宣言します。あわせてfreshness(鮮度)を設定しておくと、取り込みが止まったときに気づけます。

# models/staging/_sources.yml
version: 2
sources:
  - name: app
    schema: raw
    tables:
      - name: orders
        loaded_at_field: _ingested_at
        freshness:
          warn_after: {count: 24, period: hour}
          error_after: {count: 48, period: hour}

Staging層:1ソース1モデルで素直に整える

Staging層は、ソース1テーブルにつき1モデルを基本にします。やることはリネーム・型変換・軽いフィルタまでで、JOINや集計、ビジネスロジックは持ち込みません。これは設計原則の「Silver層にロジックを持たせない」をdbtで具体化したものです。`source()`関数で必ず参照し、テーブル名を直書きしないのがポイントです。

-- models/staging/stg_orders.sql
with source as (
    select * from {{ source('app', 'orders') }}
),
renamed as (
    select
        id                            as order_id,
        customer_id,
        cast(amount as double)        as amount,
        cast(created_at as timestamp) as created_at
    from source
    where id is not null
)
select * from renamed

命名は`stg_<ソース>_<対象>`で統一します。Staging層はビューにしておくと、ストレージを使わず常に最新を返せます。

Staging層でよく迷うのが「どこまで手を入れていいか」です。線引きを判定表にまとめておくと、レビューで揉めません。

Stagingで OKStaging では NG
カラムのリネーム(id → order_id他テーブルとのJOIN
型変換(cast(amount as double)GROUP BY による集計
NULL や不正値の除外ビジネスロジック(税率計算・区分の判定)
タイムゾーン統一・タイムスタンプ整形ウィンドウ関数による履歴処理
不要カラムの落とし込みマスタとの突き合わせによる補完

Intermediate層:再利用するロジックをまとめる(任意)

複数のstagingをまたぐJOINや、いくつものMartで使い回す共通計算がある場合だけ、Intermediate層(`int_`)を挟みます。最初から作る必要はありません。同じロジックを2か所以上で書きそうになったら切り出す、くらいの温度感がちょうどよいです。`ref()`で上流のstagingを参照します。

たとえば、注文と顧客を結合して各Martで使い回す「明細の土台」を作る例です。上流は必ず`ref()`で参照します。

Intermediate層を作るか作らないかは、判定基準を先に決めます。「なんとなく作る」と Mart 側の見通しが逆に悪くなります。

作る作らない
同じJOINを2つ以上のMartで書きそう1つのMartでしか使わない結合
複雑な履歴処理(SCD Type 2など)を共通化したい単純なリネームや型変換だけ
粒度変換(明細→日次サマリ)を複数下流で使う粒度変換が下流で1回だけ必要

SCD Type 2 の履歴処理を int 層に切り出したい場合は、dbt snapshotsでSCD Type 2を実装するで snapshot と int の分担を整理しています。

-- models/intermediate/int_orders_enriched.sql
with orders as (
    select * from {{ ref('stg_orders') }}
),
customers as (
    select * from {{ ref('stg_customers') }}
)
select
    o.order_id,
    o.amount,
    o.created_at,
    c.customer_segment
from orders o
left join customers c
    on o.customer_id = c.customer_id

Mart層:用途別に組み立てる

Mart層は、ビジネスの質問に直接答えるテーブルをドメイン別フォルダに置きます。ファクトは`fct_`、ディメンションは`dim_`で揃えると、役割がひと目で分かります。上流は必ず`ref()`で参照し、リネージ(依存関係)が途切れないようにします。

Mart 層は命名とフォルダ配置で伸縮しやすさが決まります。ドメイン別に切り、fct(事実)と dim(属性)で接頭辞をそろえます。

ドメインファクト例(fct_ディメンション例(dim_
financefct_daily_sales, fct_invoicesdim_product, dim_customer
marketingfct_campaign_touchpointsdim_channel, dim_utm
productfct_sessions, fct_eventsdim_user, dim_feature

データ量が増えて Mart のフル再計算コストが気になり始めたら、dbt incremental models の設計と落とし穴で差分更新の切り替え条件を整理しています。

-- models/marts/finance/fct_daily_sales.sql
select
    date(created_at) as sale_date,
    count(*)         as order_count,
    sum(amount)      as total_amount,
    avg(amount)      as avg_amount
from {{ ref('stg_orders') }}
group by 1

層ごとのmaterializationとテスト

層によって、テーブルの作り方(materialization)とかけるテストを変えます。`dbt_project.yml`でフォルダ単位にまとめて指定でき、個別モデルで上書きもできます。

view / table / incremental の3種類のmaterializationを速度・ストレージ・再計算コストで比較する図

層ごとの標準は次のとおりです。まずこのデフォルトから入って、必要が出てから個別に上書きします。

デフォルト理由上書きするとき
Stagingviewストレージを使わず常に最新、再計算が軽い大量スキャンで下流が重くなる場合はtableへ
Intermediateephemeral or table下流に1回しか使わないならephemeral、複数で使うならtable実行時間が長い→incremental化
MarttableBIから直接叩かれる速度が最優先データ量とコストが増えたらincrementalに切り替え
materialization主なテスト
Stagingviewnot_null・unique(主キー)
Intermediateview / ephemeral上流で担保(最小限)
Marttable(大規模はincremental)relationships・accepted_values・ビジネスルール
# dbt_project.yml(層ごとの既定materialization)
models:
  my_project:
    staging:
      +materialized: view
    marts:
      +materialized: table

# models/staging/_stg_orders.yml(テスト)
models:
  - name: stg_orders
    columns:
      - name: order_id
        tests:
          - not_null
          - unique
dbtのテスト種類(unique / not_null / relationships / accepted_values)

テストは層ごとに責務を分けます。Stagingは「ソースの契約」を守る、Martは「ビジネスの意味」を守る、と役割が違います。

入れるテスト目的
Stagingunique, not_null, relationshipsソースからの取り込みが壊れていないか
Intermediaterow_count 差分、期待値との整合結合や粒度変換で欠落や重複が出ていないか
Martaccepted_values, dbt_utils.expression_is_true, カスタムテストビジネスルールが守られているか(マイナス金額なし、日次合計 = 明細合計 など)

テストの粒度と CI 統合の実践はdbt tests 実践:generic・singular・dbt-expectations の使い分けを、CI で dbt-checkpoint を組み合わせて自動強制する運用はdbt-checkpoint とはで扱っています。

つまずきやすい実装ポイント

設計が正しくても、実装の作法でつまずくとリネージや環境切り替えが壊れます。次の4点を最初にチーム規約として決めておくと安全です。

  • 必ず ref() / source() で参照する:テーブル名を直書きすると、リネージが途切れ、開発・本番の環境切り替えも効かなくなります。
  • Stagingにロジックを入れない:JOINや集計はIntermediate以降へ。Staging層の肥大化は保守性を一気に下げます。
  • 命名を統一する:stg_/int_/fct_/dim_ を徹底すると、どの層のモデルか名前だけで分かります。
  • テストなしでマージしない:CIで dbt build(実行+テスト)を回し、壊れたモデルが本番に出ないようにします。

初回セットアップの1週間チェックリスト

ゼロから3層構造を立ち上げるとき、何から手をつけるか迷うことが多いです。実務で回している標準的な1週間の進め方を並べます。

Dayやることゴール
Day 1dbt プロジェクト初期化、Snowflake/BigQuery/Databricks の接続、リポジトリ作成dbt debug が通る
Day 2models/staging/_sources.yml で対象ソースを宣言、freshness を設定dbt source freshness が緑
Day 3主要 3〜5 テーブルの stg_ を作成、命名規則をチームで合意dbt build –select staging が通る
Day 4最初の Mart を1つ作る(例:fct_daily_sales)、materialization は tableBIから叩ける状態
Day 5Staging に unique/not_null、Mart に accepted_values を最低1つずつdbt build 全緑
Day 6CI(GitHub Actions等)で dbt build を回す、PR で通らないとマージできない設定PR で dbt build 自動実行
Day 7docs 生成と公開、ドキュメント更新の運用ルール決定dbt docs generate → 社内公開

ドキュメント運用の負担を下げたいときは、dbt-osmosis で description の自動継承を回すと、YAML 保守が現実的なコストに収まります。CIで壊れたモデルを止める仕組みは dbt-checkpoint が定番です。

まとめ

dbtで3層構造を実装する勘所は、Rawをsourcesで宣言し、Stagingは素直に整え、Martで用途別に組み立てること。そして層ごとにmaterializationとテストを変え、ref()/source()でリネージを保つことです。層を分ける考え方そのものはメダリオンアーキテクチャの解説にまとめていますので、概念と実装をあわせて押さえると、保守性の高い基盤になります。

実際の環境に3層構造を導入するとき、既存の SQL 資産の移行、CIの組み方、Snowflake / BigQuery / Databricks 固有の設定で詰まりやすい箇所があります。DE-STK の初回相談(30分・無料)では、あなたのチームの現状を聞きながら、フォルダ構成の初期案とテスト戦略のたたき台まで一緒に描けます。

あわせて読みたい dbt 関連記事

3層構造の土台ができたら、周辺トピックに広げていきます。テストと運用、コスト、モデリングの深掘り、代替ツールの視点で整理しています。

よくある質問

Intermediate層は必ず作るべきですか?

いいえ。最初は不要です。複数のstagingをまたぐJOINや、いくつものMartで使い回す共通計算が出てきてから切り出せば十分です。同じロジックを2か所以上で書きそうになったタイミングが目安です。

Martはtableとincrementalのどちらにすべきですか?

まずはtableで十分です。データ量が増えてフル再計算の時間やコストが無視できなくなったら、incrementalに切り替えます。最初からincrementalにすると、冪等性や差分条件の管理が複雑になりがちです。

dbt以外のツールでも同じ構成にできますか?

できます。層を分ける考え方はツールに依存しません。SQLとオーケストレーションがあれば再現できます。設計思想はメダリオンアーキテクチャの解説を参照してください。

Staging をビューにするとクエリが遅くなりませんか?

ソースが素直なテーブルであれば、モダンDWH(Snowflake / BigQuery / Databricks)ではビューでも遅くなりません。逆に、Staging を table にすると常に最新でなくなり、ストレージ課金も乗ります。ビューで詰まってから table 化を検討する順で問題ありません。

1ソース1モデルの原則から外していい例はありますか?

あります。同じソースが複数の粒度で使われるとき(イベントログを「セッション単位」と「日次サマリ単位」で使い分けるなど)は、Staging を目的別に stg_events_session, stg_events_daily と分けます。逆に、単純な差別化がないのに1テーブルから2つ以上の stg を作るのは避けます。

Mart のテーブル数が増えて管理しにくくなってきました。どう整理しますか?

ドメイン別フォルダの下にさらにサブフォルダを切ります(例:marts/finance/monthly/)。命名の接頭辞(fct_, dim_)と、更新頻度(daily/hourly/monthly)を組み合わせておくと、後から見て何のためのテーブルか一目で分かります。数が本当に多くなったら、セマンティックレイヤーとメトリクス管理を挟んでメトリクス定義側に責務を移す選択肢もあります。

▼ このトピックをポチポチ学ぶ

データエンジニア入門:取り込みから配膳まで

関連記事を順序立てて読みながら、ステップごとに4択クイズで理解を確認できる学習パスです。登録不要・進捗自動保存。

学習パスを始める →