← All Articles
Semantic DataXpress  ·  Technical Deep Dive

Semantic Layer: Under the Hood

A complete walkthrough of how a semantic layer is structured: entity modeling, relationship resolution, multi-role dimensions, aggregate context switching, derived metrics, and the definitions that make business data unambiguous.

Narendra Devarasetty  ·  2026
22 min read

Two dashboards disagree on last quarter's revenue. A wrong join inflates a metric by 30%. An AI assistant writes plausible SQL that silently drops half the data. These aren't edge cases. They're the default when business logic lives in scattered queries instead of a governed model.

This article is a walk-through of how I built a semantic layer from the ground up: the two-layer physical/model split, how entities and relationships are declared, how metrics are governed, how joins resolve automatically, how aggregates route safely, and how the whole thing is tested. A semantic layer is not middleware. It is the encoded knowledge of what your data means, and the engine that reads that knowledge to produce trustworthy SQL.

The examples throughout this article are drawn from SpeedDelivery, a food delivery platform connecting consumers, restaurant partners, and courier drivers across multiple cities. SpeedDelivery's data team serves three distinct audiences: an operations team tracking cancellation rates, delivery times, and zone performance; a finance team owning revenue, commission, and driver payout metrics; and a data platform team responsible for the warehouse schema and the semantic layer that sits on top of it. Their model is the thread that runs through every section here.

Is this worth building for your team?

In the age of AI, every analytics team has two personas of users: humans and AI agents. Most current implementations serve the AI persona by feeding everything at the model (table metadata, SQL query history, column profiling, whatever stats they can dump) and leaning on a high-end LLM to reason its way through. It works surprisingly often, and it fails quietly: plausible queries that join the wrong tables, filter on a status value that doesn't exist, or pick a revenue column that doesn't match the one the CFO reports.

The failure isn't the model. LLMs are very good at pattern matching and reasoning over structured data. Structure in the data context is nothing but a semantic data model: how you organize and represent your business metrics, dimensions, and hierarchies. The ceiling on what an LLM can answer correctly is set by how structured the context is.

Predictability and reproducibility are critical. Without semantic context and a confined agent scope, it is hard for an agent to come up with the same answer every time. Ask "what's our revenue YTD?" on Tuesday and Thursday and get two different numbers, and the whole system loses trust, from humans and from downstream AI consumers alike. A governed semantic layer is what makes the output deterministic: same inputs, same metric definitions, same generated SQL, same answer.

And you need a way to confirm all of this: evals. A set of canonical question-to-expected-SQL pairs (or question-to-expected-metric-value pairs) run on every model change. Without evals, you are trusting the agent's output by vibes; with them, a regression in metric resolution or query generation shows up as a failing test, not as a mis-reported number in next week's board deck.

Probably yes, build one if: two dashboards disagree on the same metric; analysts regularly ask each other which tables join "correctly"; you are planning to put an LLM in front of your warehouse (or already have); metric changes require hunting through dozens of SQL files; or new analysts take weeks to ramp because the business logic lives in tribal knowledge.

Probably not yet if: fewer than five analysts and one or two fact tables; a single BI tool with a small, stable metric set; no near-term AI integration; and metric definitions are not a source of pain in weekly reviews. Start with better dbt hygiene; revisit when any of the "yes" triggers fire.

In this article
  1. The two layers: physical truth and business meaning
  2. Defining an entity
  3. Metrics: formula, derived, and disambiguated
  4. Dimensions, model filters, and metric groups
  5. Star and snowflake schema joins
  6. Inheritance and multi-role dimensions
  7. Aggregate-aware context switching
  8. Query generation: the engine that reads the model
  9. Tests: enforcing correctness at every path
  10. Serving AI agents
  11. Common anti-patterns
  12. How this compares to alternatives
  13. Getting started

The two layers: physical truth and business meaning

Every table, column, and join path in the warehouse is declared in the physical layer. Every metric, dimension, and business concept is declared in the model layer. The model layer references the physical layer. The physical layer knows nothing about the model layer. This one-way dependency is what prevents warehouse schema changes from silently corrupting business metric definitions.

Two-Layer Separation
PHYSICAL LAYER fact · dimension · aggregate tables columns · types · canonical values join paths · cardinality · SCD definitions Owned by data engineers · changes with schema MODEL LAYER entities · metrics · dimensions inheritance · perspectives · groups model filters · aggregate sources Owned by analysts · changes with business references never reversed QUERY ENGINE · reads both, emits SQL

When an engineer renames a column in the physical layer, the model loader catches the broken reference during validation and fails immediately with a clear error. When an analyst changes a metric formula in the model layer, the warehouse schema files are untouched. Each layer evolves independently, owned by the people who understand it best.

What a physical table declaration looks like

Every table in the physical layer follows the same structure: a table block with database, schema, and name, followed by a columns array where each column declares its type, description, and optionally its primary key status or the set of valid categorical values.

delivery_fact.yaml: physical layer declaration
table:
  database: warehouse
  schema:   analytics
  name:     delivery_fact
  description: "Delivery transaction fact table. Grain: one row per delivery."

columns:
  - name: delivery_id
    type: BIGINT
    is_primary_key: true
    description: "Unique delivery identifier"

  - name: consumer_id
    type: BIGINT
    description: "Foreign key to consumer_dim"

  - name: order_date
    type: DATE
    description: "Date the order was placed"

  - name: delivery_status
    type: VARCHAR(50)
    values: [pending, accepted, in_transit, delivered, cancelled]
    description: "Current delivery status"

  - name: total_amount
    type: DECIMAL(12,2)
    description: "Total amount charged for the delivery"

The description on consumer_id names the target dimension table explicitly. This is documentation, not a parsed reference. The actual join lives in a separate relationship file and is declared once there. The physical layer describes structure; the model layer describes meaning; they do not bleed into each other.

· · ·

Defining an entity

An entity in the model layer represents a business domain: delivery, consumer, store, driver. It is the unit of meaning that analysts think in. A core model declaration answers three questions: where does the data live, what is the grain, and what is the primary time axis?

Core model declaration
model: delivery
type: core
display_name: "Delivery"
icon: "local_shipping"
status: production                 # production | beta | deprecated
source_table: delivery_fact
date_field: order_date             # primary time axis for all date filtering
grain: [delivery_id]               # one row per delivery

dimensions:
  # Time dimensions
  - name: order_date
    column: order_date
    type: date
    description: "Date the order was placed"

  - name: delivery_dt
    column: delivery_dt
    type: date
    description: "Date the order was delivered"

  - name: cancelled_dt
    column: cancelled_dt
    type: date
    description: "Date the order was cancelled (NULL if not cancelled)"

  # Identifiers
  - name: delivery_id
    column: delivery_id
    type: integer
    description: "Unique identifier for the delivery"

  # Attributes
  - name: delivery_status
    column: delivery_status
    type: categorical
    description: "Current delivery status"
    aliases: ["order status"]

  - name: order_source
    column: order_source
    type: categorical
    description: "Platform where order was placed"
    aliases: ["channel", "platform"]

metrics:
  - name: total_orders
    display_name: "Total Orders"
    description: "Count of all deliveries"
    formula: "COUNT(delivery_id)"
    type: integer
    certification: verified

  - name: total_revenue
    display_name: "Total Revenue"
    description: "Sum of all order total amounts"
    formula: "SUM(total_amount)"
    type: currency
    unit: USD
    certification: verified
    aliases: ["gross revenue", "GMV"]

  - name: avg_delivery_time
    display_name: "Average Delivery Time"
    description: "Average delivery time in minutes"
    formula: "AVG(actual_delivery_minutes)"
    type: number
    unit: minutes
    semantic_type: duration

  - name: total_cancelled
    display_name: "Cancelled Orders"
    description: "Count of deliveries with status = 'cancelled'"
    formula: "COUNT(CASE WHEN delivery_status = 'cancelled' THEN 1 END)"
    type: integer
    # cancellation_rate is declared as a derived (expression) metric below.
    # See "Derived metrics" in the next section.

groups:
  - name: order_performance
    display_name: "Order Performance"
    description: "Order volume and per-order economics"
    metrics:
      - name: total_orders
        use_cases:
          - "Monitor order volume trends"
          - "Compare across segments"
      - name: total_revenue
        use_cases:
          - "Track overall revenue growth"

  - name: delivery_operations
    display_name: "Delivery Operations"
    description: "Delivery time and quality metrics"
    metrics:
      - name: avg_delivery_time
        use_cases:
          - "Monitor delivery speed trends"
      - name: cancellation_rate
        use_cases:
          - "Track order failure rate"

default_filters:
  - dimension: delivery_status
    operator: "!="
    value: "cancelled"

The grain field is the model's correctness contract: at this grain, one row represents one thing: [delivery_id] for a delivery, [order_id, line_item_id] for a line item. It matters most when joins are involved. Joining delivery_fact to a finer-grained table fans out rows; each delivery multiplies into as many rows as it has line items, and any SUM double-counts. The grain declaration makes this risk explicit: the model author's commitment that they've handled it, through pre-aggregation, a subquery, or a distinct count. A model without a declared grain cannot be reasoned about.

The date_field is the single time axis for every date range filter and time-period comparison applied to this entity. A delivery model uses order_date; a driver payout model uses payout_date. Declared once, it flows through every query.

The status field signals the model's lifecycle stage: production means the model is stable and ready for general use; beta means it is functional but under active development and may change; deprecated means it should no longer be used and will be removed in a future release. Status is separate from metric certification: a production model can contain a mix of verified and informal metrics. Status answers "is this model ready for use?" while certification answers "is this specific calculation correct?"

Perspectives: Purpose-built views with shared governance

A perspective inherits a core model and tailors it for a specific team or use case. SpeedDelivery's operations team needs delivery time, cancellation rate, and zone-level dimensions with a default filter excluding test orders. Their finance team needs revenue, commission, and margin with a default filter for completed transactions only. Perspectives can also scope data access: a regional perspective for China has region = 'CN' baked in, so users of that perspective only see China data. All three perspectives share the same core delivery model: when the core metric formula changes, every perspective updates automatically. No duplication, no drift.

Perspective inheriting from a core model
model: delivery_operations
type: perspective
core: delivery                 # inherits source_table, date_field, all dims + metrics
display_name: "Delivery Operations"
icon: "speed"

inherits:                       # pull in dimensions from additional models
  - model: store
    join: delivery_fact__store_dim

groups:
  - name: operations_summary
    display_name: "Operations Summary"
    description: "Key operational health indicators"
    metrics:
      - name: total_orders
        use_cases:
          - "Monitor daily order volume"
      - name: avg_delivery_time
      - name: on_time_delivery_rate
      - name: cancellation_rate

sample_questions:
  - "Which stores have the highest cancellation rate this week?"
  - "How has average delivery time changed month over month?"

The resolver merges the core model's dimensions and metrics into the perspective, core items first, then the perspective's own additions. The perspective can add metrics the core doesn't have, override metric groups, append default filters, and pull in additional joined models. It cannot break the core model. Changes to the core automatically flow into every perspective that inherits it.

· · ·

Metrics: formula, derived, and disambiguated

A metric is more than a SQL formula. It is a named, versioned, governed piece of business logic with enough metadata to be used correctly by both humans and AI systems. The metadata answers three questions that a formula cannot: what does this metric mean, how should it be aggregated, and when should it be used instead of a similar one?

Formula metrics

A formula metric is a direct SQL aggregate over the source table. The formula is written with bare column names; the model loader qualifies them to table.column form at load time so the query engine never sees an unqualified reference.

Formula metric with full metadata
- name: total_revenue
  display_name: "Total Revenue"
  description: "Sum of all order total amounts including fees and surcharges"
  formula: "SUM(total_amount)"      # qualified to SUM(delivery_fact.total_amount) at load time
  type: currency
  unit: USD
  certification: verified
  aliases:
    - "gross revenue"
    - "GMV"
    - "total sales"
  preferred_for:
    - "revenue growth tracking"
    - "financial overview"
  disambiguation_hints: "Use for top-line gross revenue and GMV reporting.
    For earnings after costs and deductions, use net_revenue instead."

The certification level signals trust: informal means the metric was created by a team for their own use and hasn't been reviewed; verified means a data team member has confirmed the formula; gold means it is the company-wide standard for this concept. When two metrics match a question, certification breaks the tie.

The optional semantic type field encodes how a metric behaves mathematically: which aggregations are safe, how it should be displayed, and where it is likely to mislead. It is most important for non-additive metrics (rates, durations, semi-additive snapshots) where incorrect aggregation produces silent errors. Simple additive metrics like total_revenue typically omit this field. The types and what they imply:

Semantic typeBehaviorExampleCommon mistake
cumulative Fully additive. Safe to SUM across any dimension (region, time, store, driver). total_revenue, total_orders Double-counting when summing across overlapping time windows (e.g., summing weekly figures to get a monthly total that spans partial weeks)
count Additive across dimensions but requires DISTINCT when the grain fans out. unique_consumers, active_drivers Counting consumers from a join-fanout query without DISTINCT, producing inflated counts
ratio / rate Non-additive. Cannot be summed across any dimension. Must be recomputed from component metrics for any new slice. cancellation_rate, conversion_rate, avg_order_value Averaging rates across regions to get a total rate. The correct answer is always component_numerator / component_denominator recomputed at the new grain
duration Should be averaged, not summed. Display with its time unit. Meaningful as a weighted average when combining groups of different sizes. avg_delivery_time, p90_pickup_time Summing durations to produce a "total wait time" figure that no one asked for
currency Cumulative, but localization-sensitive. Display with currency code. Multi-currency models require explicit conversion before aggregation. net_revenue, driver_payout, commission Summing revenue across markets with different currencies without FX normalization
semi-additive Additive across some dimensions (e.g., stores, regions) but not across time. The correct time aggregation is a snapshot: last value, average, or beginning/end of period. active_inventory_count, account_balance, pending_orders Summing a daily inventory snapshot across 30 days to get a "monthly total." This produces a number 30× too large with no business meaning

Semi-additive metrics deserve extra attention because they are the most common source of silent errors in operations and finance models. If you have a metric that measures a state at a point in time (a balance, a count of active entities, an inventory level), it is semi-additive. The right query for "average daily active drivers last month" is AVG(daily_snapshot), not SUM(daily_snapshot). Declaring the semantic type makes this intent explicit and prevents the wrong aggregation from being applied by default.

The disambiguation hints field is operational guidance: free-text written by the metric author that explains when to use this metric and when to use a different one instead. Every disambiguation hint in the model was written to prevent a specific, observed confusion from recurring.

Derived metrics

Some business concepts are not raw aggregates but compositions of other metrics. Revenue per order is total revenue divided by total orders. Gross margin is net revenue divided by total revenue. These are declared as expression metrics: they reference other named metrics instead of writing out a formula.

Derived (expression) metrics
# Base formula metrics
- name: total_revenue
  formula: "SUM(total_amount)"
  type: currency

- name: total_orders
  formula: "COUNT(delivery_id)"
  type: integer

# Derived metrics: each references formula metrics above
- name: revenue_per_order
  display_name: "Revenue Per Order"
  expression: "total_revenue / NULLIF(total_orders, 0)"
  type: currency
  semantic_type: ratio
  disambiguation_hints: "Average basket size. Do not sum across regions. Recompute from components."

- name: cancellation_rate
  display_name: "Cancellation Rate"
  expression: "total_cancelled / NULLIF(total_orders, 0)"
  type: percentage
  semantic_type: rate
  disambiguation_hints: "Share of orders cancelled before completion. Non-additive. Never sum across regions."

# total_cancelled is a formula metric: COUNT(CASE WHEN delivery_status = 'cancelled' THEN 1 END)
# At load time the resolver expands cancellation_rate to a flat formula:
# "(COUNT(CASE WHEN delivery_fact.delivery_status = 'cancelled' THEN 1 END))
#  / NULLIF((COUNT(delivery_fact.delivery_id)), 0)"

At load time the resolver substitutes each referenced metric name with its fully-qualified formula, producing a single flat SQL expression. The substitution is longest-name-first to prevent partial matches. Chaining is not allowed: an expression metric cannot reference another expression metric. This is validated at load time with a single forward pass, so circular or deep chains are caught before they can cause confusion at query time.

Canonical filter values

For every enumerated column in the physical layer, the exact valid values are declared alongside the column definition, as shown in the delivery_status column above. These canonical values serve three purposes: filter validation (programmatic filters match against exact strings, eliminating case-sensitivity bugs and empty results from typos); UI dropdowns (analysts pick from a controlled list rather than typing free text); and AI agent grounding (values are extracted at load time and injected into the agent's prompt context, so when a user asks "show me cancelled deliveries," the LLM writes cancelled (the declared value), not a guess).

· · ·

Dimensions, model filters, and metric groups

Dimensions

A dimension is any attribute you can slice or filter by: order date, delivery status, store category, consumer region, zone name. Dimensions that come directly from the source fact table are declared inline on the model. Dimensions that come from joined tables are pulled in through the inherits list (more on that in the inheritance section). Either way, the analyst sees a flat list of available dimensions and picks one; the join resolution is invisible.

A special case worth knowing: degenerate dimensions: identifiers stored directly on the fact table with no corresponding dimension table (order_number, trip_code). In a delivery model, delivery_id is the canonical example: it is the grain column, always available for filtering and grouping, requiring no join. Degenerate dimensions are declared as regular inline dimensions and emit no JOIN clause. The most common mistake is omitting them from aggregate table declarations. If an aggregate is built at daily grain without a delivery_id column, any query filtering by a specific delivery must fall back to the full fact table.

Model filters

A model's default_filters silently scope every query run against it. They are applied before any user filter and cannot be overridden by user input unless the perspective explicitly sets override_default_filters: true.

Default filters: scoping a model to active deliveries
default_filters:
  - dimension: delivery_status
    operator: "!="
    value: "cancelled"
  - dimension: delivery_status
    operator: "!="
    value: "refunded"

This is how a team-specific perspective stays clean by default: at SpeedDelivery, the finance perspective excludes test orders and the operations perspective excludes archived zones. The filters live in the model definition, reviewed in a pull request, visible to everyone. When a query arrives, the engine prepends the default filters before evaluating any user-supplied conditions.

Default filters also participate in aggregate routing. Before an aggregate table can be used, the engine verifies that the user's filters, plus the model's defaults, are compatible with the content restrictions built into the aggregate. A mismatch forces a fallback to the full fact table, every time.

Metric groups

Metrics are organized into named groups for navigation and context. A group has a display name, a description, an ordered list of metrics, and use-case hints for each metric that explain when it is most relevant. Groups can nest up to four levels deep. A finance perspective might have a top-level Financial group containing Revenue and Margin subgroups, each with their own ordered metrics and use-case annotations.

· · ·

Star and snowflake schema joins

Every join between tables is declared once in the physical layer as a named relationship. The model layer references join names, not join conditions. The query engine resolves the condition at query time from the physical layer declaration. This means a join condition is defined in exactly one place, reviewed in one PR, and changes in one place. A model that needs the same join three times still references the same relationship name three times.

Star schema: delivery fact with surrounding dimensions
delivery_fact consumer_id · store_id driver_id · zone_id consumer_dim consumer_id · region · tier store_dim store_id · category · region zone_dim zone_id · zone_name · market driver_dim driver_id · tier · city consumer_id store_id zone_id driver_id

The star schema is the default pattern: a central fact table with direct foreign key joins to surrounding dimension tables. Each relationship is a named many_to_one declaration in the physical layer.

Relationship declaration: star schema join
name: delivery_fact__consumer_dim
type: many_to_one
join_type: LEFT JOIN
left:
  table: delivery_fact
  column: consumer_id
right:
  table: consumer_dim
  column: consumer_id
# join_condition is auto-derived from left/right columns

The type field (many_to_one, one_to_one, one_to_many) is advisory, not runtime-enforced. It documents the expected cardinality and is used by the query engine to reason about aggregate routing (a metric that requires a one_to_many join cannot safely use a pre-aggregated table, for example). It does not validate row counts at query time. If the actual data in your warehouse violates the declared cardinality (a many_to_one join that is secretly many_to_many at runtime), the engine will not detect it, and the result will silently double-count. The cardinality declaration is your data contract with future model users. Make it accurate. Test it with a COUNT vs COUNT DISTINCT check on the join key before declaring it.

Snowflake joins extend the star by chaining through intermediate dimension tables, for example from a delivery to its consumer, then from the consumer to their historical address. Multi-hop paths are declared using a double-underscore convention in the join name: delivery_fact__consumer_dim__consumer_address. The resolver validates chain continuity at load time: each hop's right table must match the next hop's left table. A broken chain raises an error before any query can be served.

Slowly changing dimension joins

Some dimension tables maintain a history of how entity attributes changed over time. A consumer's region may have changed three times in two years. Joining without accounting for the time dimension produces incorrect or multiplied results: the query picks up multiple historical rows per consumer. An SCD join declaration adds temporal predicates automatically.

SCD relationship declaration
name: delivery_fact__consumer_address_scd
type: scd                          # signals: apply temporal predicate at query time
join_type: LEFT JOIN
left:
  table: delivery_fact
  column: consumer_id
right:
  table: consumer_address
  column: consumer_id
  start_date: scd_start_dt         # column: when this record became valid
  end_date: scd_end_dt             # column: when it stopped being valid
join_condition: delivery_fact.consumer_id = consumer_address.consumer_id

# At query time, the engine adds temporal predicates automatically:
# AND consumer_address.scd_start_dt <= delivery_fact.order_date
# AND consumer_address.scd_end_dt   >  delivery_fact.order_date

The SCD relationship is declared once. Every query that uses it gets the correct temporal predicate applied automatically. The model author writes the join name; the engine handles the time logic. SCD relationships are validated at load time: a missing start_date or end_date raises an error immediately rather than producing silently incorrect results.

SCD convention: end_date is exclusive

The predicate above is scd_end_dt > event_date, strictly greater-than, because this implementation treats scd_end_dt as the first day the record is no longer valid (exclusive upper bound). If your warehouse instead stores scd_end_dt as the last day the record is valid (inclusive), use >= in the generated predicate. This is the single most bug-prone line in any SCD implementation; pick one convention and enforce it across your dimension pipeline before the model layer is built.

· · ·

Inheritance and multi-role dimensions

Two mechanisms handle reuse in the model layer. The core field handles vertical inheritance (a perspective extending a core model). The inherits list handles horizontal composition, pulling dimensions and metrics from an entirely different entity model through a declared join.

Models compose through three levels. Dimension entities (L1) are the reusable building blocks: consumer, store, driver, market. Core entities (L2) inherit from them and add fact-specific metrics. Perspectives (L3) inherit from a core entity and shape it for a specific team or use case.

Three-Level Inheritance: Dimension → Core → Perspective
L1 L2 L3 consumer store driver market delivery unit_economics delivery_ops plan_type subscription sub_revenue sub_lifecycle Max depth: 3 L4+ rejected at load

Three rules that keep inheritance manageable

Inheritance without guardrails creates the same problems it solves. Implicit transitive dependencies, unbounded depth, and duplicate names from multi-path inheritance are how object-oriented hierarchies collapse in application code. Three strict constraints prevent this in the model layer.

Rule 1: Explicit, not transitive. If entity A inherits B and C, and entity D inherits A, then D does not automatically gain B and C. D must list them explicitly in its own inherits block. Every model's complete dependency graph is readable from its own file. You never need to trace a chain of files to understand what a model includes. The cost is some declaration repetition. The benefit is that no model has invisible dependencies.

Rule 2: Maximum depth of three levels. Dimension (L1) → Core (L2) → Perspective (L3). No deeper nesting. The model loader enforces this with a hard depth limit: any chain exceeding three levels raises a ValueError at load time. Deeper hierarchies create resolution ambiguity and make the model harder to reason about. Three levels cover every real composition pattern encountered in practice.

Rule 3: Circular dependency detection. The model loader tracks a visited set during recursive resolution. If a model appears twice in the resolution chain, the loader raises a ValueError with the full cycle path (A → B → C → A) before any query can be generated. This is caught at load time, not at query time. You cannot publish a model with a circular dependency.

Cross-model composition

A delivery model that needs to expose consumer attributes (tier, region, signup date) does not redeclare those attributes. It inherits them from the consumer model through a declared join path. The resolver pulls all consumer dimensions into the delivery model's namespace, tagging each with the join name it requires. At query time, when a user selects a consumer dimension, the engine adds that join to the query automatically.

Cross-model inheritance: pulling dimensions from other entities
inherits:
  - model: consumer
    join: delivery_fact__consumer_dim
    # → consumer_id, consumer_tier, consumer_region, ... all added to namespace
    # → each tagged with _join: "delivery_fact__consumer_dim"

  - model: zone
    join: delivery_fact__zone_dim
    # → zone_name, zone_market, ... all added to namespace

Cross-fact composition: the Customer 360 pattern

The most powerful form of composition combines metrics from multiple fact tables into a single model. A Customer 360 view needs order metrics (revenue, order count), support metrics (ticket count, resolution time), and engagement metrics (login frequency, feature usage). Each lives in a different fact table with its own grain. The semantic layer composes them through a shared dimension anchor.

Customer 360: Cross-Fact Metric Composition
customer_360 source: customer_dim unified view of all customer metrics order_fact total_revenue total_orders support_fact total_tickets avg_resolution_time engagement_fact login_count feature_usage_score customer_dim__order_fact customer_dim__support_fact customer_dim__engagement_fact customer_dim shared anchor
Customer 360: composing metrics from multiple fact tables
model: customer_360
type: core
source_table: customer_dim          # the shared anchor
date_field: created_at

dimensions:
  - name: customer_id
    column: customer_id
    type: integer
  - name: customer_tier
    column: tier
    type: categorical

metrics:
  - name: total_customers
    display_name: "Total Customers"
    formula: "COUNT(customer_id)"
    type: integer

inherits:
  - model: order                    # pulls total_revenue, total_orders
    join: customer_dim__order_fact

  - model: support                  # pulls total_tickets, avg_resolution_time
    join: customer_dim__support_fact

  - model: engagement               # pulls login_count, feature_usage_score
    join: customer_dim__engagement_fact

When an analyst queries total_revenue and total_tickets together, the query engine automatically includes both JOINs. When they query only total_revenue, only the order_fact JOIN is added. The model author declares the composition once; the engine handles join optimization per query. This is how a single model serves questions that would otherwise require manual SQL across three different fact tables.

Why cross-fact composition matters

Prevents metric duplication. Without composition, teams copy metric definitions into their own models. Two definitions of "total_revenue" will drift. With composition, there is one definition in the order model, inherited everywhere it is needed.

Enables unified analysis. "Show me customers with high revenue but low engagement" requires metrics from two fact tables. Cross-fact composition makes this a single query against a single model.

Simplifies onboarding. A new analyst does not need to know which fact table owns which metric. They see one model with all customer metrics, pick what they need, and the engine handles the joins.

Conformed dimensions

Cross-model inheritance enables conformed dimensions: dimension tables that carry the same meaning regardless of which fact table you reach them through. At SpeedDelivery, consumer_dim means the same thing whether you join to it from delivery_fact, payment_fact, or support_ticket_fact. A report comparing revenue per consumer tier against support ticket rate per consumer tier is only meaningful if both models join the same consumer_dim with the same definition of "tier"; conformed dimensions guarantee this. When the consumer model is updated, every domain that inherits from it picks up the change automatically. The practical implication: dimension models should be designed to be shared, not owned by a single fact team. A conformed dimension belongs to the data platform.

Multi-role dimensions

Some fact tables join to the same dimension table more than once through different foreign keys. A delivery has both an ordering consumer and a receiving consumer. Both are rows in consumer_dim. Both are referenced by separate foreign keys in delivery_fact. Without role separation, the second join produces a SQL alias collision and "show me revenue by consumer region" is ambiguous: which consumer?

Role-Based Joins: Same Dimension, Different Semantic Roles
delivery_fact consumer_id (ordering consumer) recipient_consumer_id (receiving consumer) consumer_dim AS consumer consumer_dim AS recipient_consumer many_to_one role: recipient LEFT JOIN consumer_dim AS recipient_consumer ON delivery_fact.recipient_consumer_id = recipient_consumer.consumer_id
Multi-role inheritance: same physical table, two roles
inherits:
  - model: consumer
    join: delivery_fact__consumer_dim
    # standard role: consumer_region, consumer_segment, consumer_tier ...

  - model: consumer
    join: delivery_fact__recipient_consumer_dim
    role: recipient
    # role-prefixed: recipient_consumer_region, recipient_consumer_segment ...
What role-based inheritance resolves automatically

1. Name prefixing. All dimensions from the second inheritance get the role prefix: recipient_consumer_region, recipient_consumer_segment, recipient_consumer_tier. No name collisions, no ambiguity.

2. SQL aliasing. The query engine emits LEFT JOIN consumer_dim AS recipient_consumer with the join condition rewritten to use the aliased table name. Two joins to the same physical table, each with its own alias.

3. UI separation. The model browser shows two distinct groups: "Consumer" and "Recipient Consumer", each with its own set of dimensions. An analyst selecting "recipient consumer region" gets unambiguous SQL with no extra configuration.

Without role qualifiers, inheriting consumer twice would produce duplicate dimension names, ambiguous SQL, and a broken UI. The role is validated at load time: a duplicate inheritance without a role raises an error before any query is generated.

Multi-hop formula resolution

Sometimes a metric needs a column that is two or more joins away from the source table. A delivery metric might need a market-level attribute from market_dim, reachable only through delivery_fact → store_dim → market_dim. Requiring the model author to flatten this into a single-hop reference would mean duplicating columns across physical tables.

Metric formulas support multi-hop relationship paths using the same double-underscore naming convention as join names:

Single-hop and multi-hop formula references
# Single-hop: one join (delivery_fact → driver_payout)
formula: "SUM(delivery_fact__driver_payout.driver_payout_amount)"

# Multi-hop: two chained joins (delivery_fact → store_dim → market_dim)
formula: "SUM(delivery_fact__store_dim__market_dim.region_revenue)"

The model loader splits the path on double-underscores into consecutive hops and validates chain continuity: the right table of hop N must equal the left table of hop N+1. If any hop's relationship is undefined or the chain is discontinuous, the loader raises an error at load time. All intermediate joins are collected and passed to the query engine, which emits them in the correct order. The model author writes a single path string. The resolution machinery is entirely hidden.

· · ·

Aggregate-aware context switching

Pre-aggregated tables trade flexibility for speed. SpeedDelivery's daily delivery aggregate pre-computes totals by date, zone, store, and status, reducing a 100-million-row scan to 10,000 rows for common queries. But an aggregate is not a general-purpose replacement for the fact table. It was built at a specific grain, under specific assumptions, and possibly covering only a subset of rows. Using it for the wrong query produces a number that looks right and is wrong.

The semantic model declares aggregate sources directly on the core model. Every alternative is explicit, versioned, and reviewed.

Aggregate source declaration on a core model
aggregate_sources:
  - table: daily_delivery_agg
    date_field: order_date

    column_mappings:              # metric name → aggregate table column
      total_revenue:    total_amount
      total_orders:     delivery_count
      total_cancelled:  cancelled_count
      total_commission: commission_amount
      total_discounts:  discount_amount

    content_filters:              # restrictions this aggregate was built with
      - dimension: delivery_status
        operator: "!="
        value: "cancelled"

The column_mappings field tells the engine how to translate metric names into the aggregate table's column names. The content_filters field declares what subset of data the aggregate was built over. These two fields together are what make the routing decision safe.

The routing decision

Before every query, the engine evaluates whether the aggregate can safely serve the entire request. The check is binary: the aggregate answers the whole query, or it answers none of it. No partial routing. The rules are evaluated in order, and the first failure short-circuits the check immediately.

Aggregate Eligibility: The Nine-Rule Check
Incoming Query metrics + dims + filters ELIGIBILITY CHECK 1. Every formula metric has a mapped aggregate column 2. Every expression metric's base metrics are also mapped 3. No cross-fact joins required 4. No aggregation override configured 5. All join FK columns exist on aggregate 6. All selected dimensions exist on aggregate (incl. filters & default filters) 7. No calculated attributes present 8. Content filters compatible with query filters (operator-aware) 9. Date field exists on aggregate (required for SCD joins & time periods) ALL 9 PASS daily_delivery_agg ANY FAIL delivery_fact full table scan Pre-aggregated, faster query Always correct, potentially slower
Three rules that need context

Rule 3: No cross-fact joins required. Some metrics reference columns reachable only through a join to a second fact table (e.g., a delivery metric that needs a column from payment_fact). Pre-aggregated tables are built from a single fact table and cannot resolve those cross-fact paths. If any requested metric requires such a join, the aggregate is ineligible regardless of everything else.

Rule 4: No aggregation override configured. Individual metrics can declare force_fact_table: true to opt out of aggregate routing permanently, typically for metrics whose definition requires row-level access that no aggregate can replicate (e.g., a percentile computed over the full distribution). If any metric in the query carries this flag, the aggregate is bypassed for the entire query.

Rule 7: No calculated attributes present. Calculated attributes are dimensions whose values are derived at query time from an expression over raw columns (e.g., CASE WHEN total_amount > 100 THEN 'high' ELSE 'low' END AS order_tier). Because they require access to the underlying row-level columns, they cannot be satisfied by a pre-aggregated table that only stores pre-computed totals.

Why content filter compatibility is the subtlest rule

The aggregate above excludes cancelled deliveries. If a user queries revenue by zone with no status filter, routing to the aggregate silently drops all cancelled-order revenue from the total. The number looks plausible. It is wrong by the cancellation amount, which varies by zone, day, and season.

The engine checks three cases: does the user filter to only delivered orders (satisfies the restriction)? Does the user filter to non-cancelled orders exactly (matches the restriction)? Or does the user apply no status filter at all? Only the first two cases allow aggregate routing. The third always falls back to the full fact table.

The routing decision is also irreversible within a query. An early prototype tried pulling some metrics from the aggregate and others from the fact table in a single response. The results were subtly wrong when the two sources interacted: different grains, different implicit filters, combined totals that didn't reconcile. Eliminating partial routing eliminated an entire class of silent errors and reduced the routing logic to a single, auditable decision point.

"The aggregate is not a shortcut. It is a governed alternative that earns its place by passing every eligibility check, every time."

· · ·

Query generation: the engine that reads the model

Everything defined in the model (entities, joins, metrics, filters, aggregate sources) is consumed by a single pure function. Given a perspective ID, a list of metrics, a list of dimensions, a filter tree, and a date range, it returns SQL. No side effects, no randomness. The same inputs always produce the same output.

The engine does not make decisions. All decisions are encoded in the model: which joins are valid, which aggregates are eligible, which filters apply by default. The engine executes those decisions deterministically in eight steps. This is what makes the output traceable: every clause in the generated SQL points back to a specific declaration in the model.

Eight-Step Generation Pipeline
0102 0304 0506 0708 Load model Build maps Collectjoins Check agg Build JOINs Build SELECT Build WHERE AssembleSQL SQL + metadata
Model → SQL: end-to-end example
# SpeedDelivery - operations team querying 2025 annual delivery performance
perspective: "delivery_operations"
metrics:     ["total_revenue", "cancellation_rate"]
dimensions:  ["order_date__month", "zone_name"]
filters:     [{dimension: "delivery_status", op: "!=", value: "cancelled"}]
date_range:  ["2025-01-01", "2025-12-31"]

# Generated SQL  (daily_delivery_agg selected - user filter satisfies content restriction)
SELECT
  DATE_TRUNC('MONTH', daily_delivery_agg.order_date)  AS order_date__month,
  zone_dim.zone_name                                    AS zone_name,
  SUM(daily_delivery_agg.total_amount)                  AS total_revenue,
  SUM(daily_delivery_agg.cancelled_count)
    / NULLIF(SUM(daily_delivery_agg.delivery_count), 0) AS cancellation_rate
FROM analytics.daily_delivery_agg AS daily_delivery_agg
LEFT JOIN zone_dim
  ON daily_delivery_agg.zone_id = zone_dim.zone_id
WHERE
  daily_delivery_agg.delivery_status != 'cancelled'   # user filter - always emitted to WHERE
  # note: the aggregate already excludes cancelled rows by construction;
  # this filter is a no-op at runtime but is still written out for traceability
  AND daily_delivery_agg.order_date
        BETWEEN DATE '2025-01-01' AND DATE '2025-12-31'
GROUP BY
  DATE_TRUNC('MONTH', daily_delivery_agg.order_date),
  zone_dim.zone_name
LIMIT 1000

The SELECT builds dimension expressions first, applying DATE_TRUNC wrapping for granularity suffixes like __month, then metrics, using aggregate column formulas when the aggregate was selected or original fact formulas otherwise. The WHERE applies default model filters, then user filters, then the date range. Every step is mechanical: no inference, no optimization, no black box.

· · ·

Tests: enforcing correctness at every path

A semantic layer with a bug in a formula is more dangerous than no semantic layer at all. Without a governed model, analysts know they are on their own and verify everything. With one, they trust the output and stop checking. The test suite is what earns that trust: not by checking that queries run, but by asserting the exact SQL produced for every scenario.

Exact SQL assertion: aggregate fallback test
def test_aggregate_content_filter_fallback(self):
    # No status filter → aggregate cannot guarantee it excludes cancellations
    # → must fall back to the full fact table
    result = generate_sql(
        perspective="p_delivery_economics",
        metrics=["total_revenue"],
        dimensions=["zone_name"],
        filters=[],
        model_data=self.model_data,
    )
    expected = normalize("""
        SELECT
          zone_dim.zone_name AS zone_name,
          SUM(delivery_fact.total_amount) AS total_revenue
        FROM analytics.delivery_fact AS delivery_fact
        LEFT JOIN zone_dim ON delivery_fact.zone_id = zone_dim.zone_id
        GROUP BY zone_dim.zone_name
        LIMIT 1000
    """)
    self.assertEqual(normalize(result["sql"]), expected)

The tests do not assert that output "contains" a keyword. They assert the exact SQL string after whitespace normalization. A test that asserts partial output can pass while the generated SQL is subtly wrong: wrong join order, wrong alias, wrong filter placement. Exact output forces every clause to be correct. When a test fails, the diff between expected and actual shows precisely what changed.

The test suite covers: every filter operator tested independently; nested AND/OR/NOT filter groups including deep nesting; every aggregate eligibility rule including boundary cases; every date granularity level; SCD temporal predicates; role-based join aliasing; expression metric expansion; inheritance merging; formula qualification with string literal preservation; model filter propagation; and time-period comparison expressions (YoY, MoM, QoQ).

ON THE TEST SUITE
Tests are not a quality gate. They are what makes the model editable

The semantic layer has many interacting behaviors: inheritance, formula qualification, aggregate routing, join resolution, filter normalization, temporal logic. A change to one part often has non-obvious effects on another. Without exact-output tests, changes accumulate invisible regressions. With them, any regression shows up immediately as a specific diff on a specific test. The suite is the mechanism that makes it safe to iterate on a model that other people depend on.

· · ·

Serving AI agents

An LLM asked to "show revenue by region" will generate SQL. Without grounding, that SQL is a plausible guess: it might pick the wrong revenue column, join to the wrong region table, or filter on a status value that doesn't exist. The semantic layer exists to prevent exactly this failure mode. It provides the structured context that turns a language model from a guess generator into a reliable query author.

What the agent receives

The planner, the agent that decomposes a user's question into sub-tasks, does not see the full model. It sees a lean YAML summary produced by a server-side function that flattens inherited models and strips everything the planner doesn't need to make a plan. Formulas, join paths, physical column names, and SQL aliases are deliberately omitted: the planner picks metrics and dimensions by name; the query worker (a separate agent, downstream) resolves them to SQL. This keeps the planner's prompt small and prevents it from inventing SQL-level decisions that belong to the engine.

The summary is rendered as YAML and cached into the planner's system prompt. Here is the exact shape served to the planner for SpeedDelivery's delivery-operations perspective:

Actual planner context (YAML, trimmed)
perspective_id: p_delivery_operations
name: Delivery Operations
description: Ops view of deliveries: volume, speed, cancellation.
source_table: analytics.delivery_fact
date_field: order_date
requires_time_window: true
models: [delivery, store, zone]

metrics:
  - name: total_revenue
    description: Sum of all order total amounts.
    aliases: ["gross revenue", "GMV"]
  - name: total_orders
    description: Count of all deliveries.
  - name: cancellation_rate
    description: Share of orders cancelled before completion.

dimensions:
  - name: order_date
    type: date
    description: Date the order was placed.
  - name: delivery_status
    description: Current delivery status.
    values: [pending, accepted, in_transit, delivered, cancelled]
  - name: zone_name
    description: Delivery zone the order was fulfilled in.

Every field in this payload is load-bearing:

What's deliberately not in the payload: SQL formulas, physical column names, table aliases, join paths, and default filters. The planner doesn't need them. It names what it wants, and the downstream query worker + engine resolve the rest. This separation is why the planner prompt stays under a few thousand tokens even on perspectives with dozens of metrics and joined entities.

Disambiguation in practice

When a user asks "what's our revenue?", the planner sees two candidates in its YAML: total_revenue with description "Sum of all order total amounts" and aliases [gross revenue, GMV], and net_revenue with description "Revenue after platform fees and driver payouts" and aliases [take rate revenue]. The description plus alias list is what resolves the ambiguity: "GMV" routes cleanly to total_revenue, "take rate" to net_revenue. When the user's wording matches neither well, the planner emits a clarifier rather than guessing. The same pattern applies across every ambiguous concept in the model: active_users vs engaged_users, order_date vs delivery_date, consumer_region vs store_region.

Richer governance metadata, semantic types and free-text disambiguation hints, lives in the full model layer and is available to downstream agents and UI surfaces (e.g., a model-browser tooltip). It is intentionally kept out of the planner's prompt context to keep that prompt lean; the planner's job is to pick which metric, not to reason about aggregation semantics.

Canonical values prevent hallucination

When the user says "show me cancelled orders", the planner needs to produce a filter. Without canonical values, it might emit 'Cancelled', 'CANCELLED', or 'canceled', any of which would return zero rows with no error. With canonical values declared on the dimension ([pending, accepted, in_transit, delivered, cancelled]) and included in the YAML summary above, the planner's sub-task metadata carries cancelled verbatim. The values are not suggestions; they are the contract between the model and every consumer of it, human or machine.

The AI advantage of a semantic layer

A semantic layer built for human analysts becomes dramatically more valuable when AI agents enter the picture. Every piece of metadata that helps a human choose the right metric (descriptions, aliases, canonical values) helps an LLM make the same choice. The investment in governing your metrics pays off twice: once for your team, and again for every AI system that consumes the model.

· · ·

Common anti-patterns

Building a semantic layer is straightforward. Building one that stays maintainable as it grows is harder. These are the patterns that cause the most pain, observed across multiple implementations.

ANTI-PATTERN 01
Deep inheritance chains

A model that inherits from a model that inherits from a model creates resolution ambiguity and makes debugging impossible. The three-level limit (dimension → core → perspective) exists for a reason. If you find yourself wanting a fourth level, you're solving an organizational problem with a technical mechanism. Flatten the hierarchy and use explicit composition instead.

ANTI-PATTERN 02
Premature aggregate optimization

Building aggregates before you understand query patterns leads to aggregates that don't match real usage. The eligibility check will route around them, and you'll have paid the ETL cost for nothing. Start with the fact table. Instrument which queries are slow. Build aggregates for the patterns that actually occur, with the dimensions and filters that users actually apply.

ANTI-PATTERN 03
Duplicating definitions across models

Two models that both define total_revenue with slightly different formulas will produce different numbers for the same question. This is the original problem the semantic layer was built to solve. If you find yourself copying a metric definition, stop. Extract it to a shared model and inherit from there. One definition, one formula, one truth.

ANTI-PATTERN 04
Skipping canonical values

Every enumerated column should declare its valid values. Skipping this step saves five minutes during model authoring and costs hours when a filter silently returns zero rows, or an AI agent hallucinates a value that doesn't exist. Canonical values are not optional metadata; they are the contract that makes filters reliable.

ANTI-PATTERN 05
Mixing physical and model concerns

A metric formula that includes a hardcoded table name, or a model that references a column not declared in the physical layer, breaks the separation that makes the system maintainable. The physical layer owns structure. The model layer owns meaning. When they bleed into each other, schema changes become metric changes, and the whole point of the separation is lost.

ANTI-PATTERN 06
Testing for presence instead of correctness

A test that asserts the output "contains SELECT" will pass for almost any generated SQL, correct or not. Tests must assert exact output. The cost is more verbose test cases. The benefit is that any regression (a wrong alias, a missing join, a misplaced filter) shows up immediately as a failing test with a clear diff.

ANTI-PATTERN 07
Not treating the model as code

A semantic layer that bypasses code review is a semantic layer that accumulates silent errors. Every change to a metric formula, a join declaration, or a canonical value list should go through a pull request, be reviewed by someone who understands the business domain, and be validated by the test suite before merge. The model is code. Treat it like code.

ANTI-PATTERN 08
Shipping the model without earning trust

The most common failure mode is not technical: the model exists, it works, and analysts keep writing raw SQL anyway. Trust has to be earned. Seed the model with a small number of verified or gold metrics that match an existing, trusted dashboard exactly. Numbers have to reconcile to the cent. Pair early adopters with a reviewer who can fix gaps in hours, not weeks. Make the generated SQL visible to users, not hidden behind an API. Analysts trust what they can read. Adoption is a process, not a launch.

· · ·

How this compares to alternatives

Several tools occupy the semantic layer space. Each makes different trade-offs. Understanding where they differ helps clarify what this implementation prioritizes.

ToolPrimary focusTrade-off
dbt Semantic Layer (MetricFlow) Metrics defined alongside dbt models; served via a query API. Strong dbt-native workflow; metrics referenceable from BI tools. Centered on metrics, not the full entity/perspective/inheritance graph. Aggregate routing and governance metadata (disambiguation hints, certification) are lighter than what this article describes.
LookML (Looker) Tightly coupled semantic layer and BI tool. Explores, dashboards, and metrics all defined in one system. Vendor lock-in. The semantic layer only serves Looker; other tools must query raw tables or duplicate definitions.
Cube API-first semantic layer. Exposes metrics via REST/GraphQL/SQL. Strong caching and pre-aggregation. Less emphasis on governance metadata (disambiguation hints, certification). Focused on serving applications, not analyst exploration.
Malloy A new query language where entities, joins, and measures are first-class, not bolted on. Compiles to SQL. A language, not a platform: you adopt a new syntax instead of declarative YAML, and governance/aggregate-routing are outside the language's scope.
AtScale Enterprise semantic layer with MDX/DAX compatibility. Designed for large organizations with existing BI investments. Proprietary. Heavy infrastructure requirements. Optimized for backward compatibility with legacy BI tools.

What this implementation prioritizes

Two-layer separation. Physical schema and business model are explicitly decoupled. Schema changes break at load time, not silently at query time. Most alternatives blur this boundary.

AI-native metadata. Disambiguation hints, semantic types, and canonical values are first-class fields, not afterthoughts. The model is designed to be consumed by LLMs as much as by humans.

Aggregate routing with safety guarantees. Pre-aggregated tables are declared with content filters and eligibility rules. The engine proves safety before routing; it never guesses. Most alternatives either skip aggregates entirely or require manual routing decisions.

Exact-output testing. The test suite asserts precise SQL for every scenario. Regressions are caught as diffs, not as user-reported bugs weeks later.

No vendor lock-in. The model is YAML files. The engine is a pure function. You can swap the query executor, change your warehouse, or integrate with any BI tool without rewriting definitions.

· · ·

Getting started

You don't need to model your entire warehouse before the semantic layer provides value. Start small, prove the pattern, then expand. Here's the path that works.

File structure that scales

Recommended directory layout
semantic_layer/
  physical/                   # warehouse structure
    tables/
      delivery_fact.yaml
      consumer_dim.yaml
      store_dim.yaml
    relationships/
      delivery_fact__consumer_dim.yaml
      delivery_fact__store_dim.yaml

  models/                     # business meaning
    dimensions/               # L1: reusable dimension entities
      consumer.yaml
      store.yaml
    core/                     # L2: fact-based entities
      delivery.yaml
    perspectives/             # L3: team-specific views
      delivery_operations.yaml
      delivery_finance.yaml

  tests/                      # exact-output test cases
    test_delivery_metrics.py
    test_aggregate_routing.py
    test_inheritance.py

Physical and model layers live in separate directories. Within models, the three inheritance levels (dimensions, core, perspectives) are explicit in the folder structure. Tests live alongside the definitions they validate. When someone asks "where is the revenue metric defined?", the answer is discoverable from the directory layout alone.

· · ·

A model worth trusting

Every piece described here (entity definitions, relationship declarations, disambiguation hints, model filters, aggregate sources, inheritance chains) exists to answer the same question: why did this query produce this number?

The physical layer says which columns the data came from. The model layer says what they mean. The formula qualification pass shows how metric formulas map to table columns. The aggregate content filters document which rows are present. The SCD declarations show how historical joins are resolved. The test suite defines what "correct" looks like for every combination of these behaviors.

An analytics system that can answer "why?" is one that people will actually use to make decisions, and come back to investigate when a number surprises them. That is what a semantic layer is for.

Next: How SpeedDelivery's semantic layer detects schema drift and keeps itself current →