Building a Self-Service Analytics Platform
A comprehensive guide to enabling business users with self-service analytics—covering semantic layers, metrics stores, governance, and organizational change management
Self-service analytics promises to democratize data access and reduce bottlenecks on data teams. But most implementations fail—creating data silos, inconsistent metrics, and frustrated users. This guide presents a pragmatic approach that balances autonomy with governance, enabling true self-service without chaos.
- Familiarity with data warehousing concepts
- Understanding of BI tools (Tableau, Looker, Power BI)
- Basic SQL knowledge

Building a Self-Service Analytics Platform
The Self-Service Paradox
Every organization wants self-service analytics. Business users want answers without waiting for the data team. Data teams want to escape ad-hoc request queues. Leadership wants faster, data-driven decisions.
Yet most self-service initiatives fail. They either produce chaos—inconsistent metrics, duplicated work, and data silos—or fade into disuse as users discover the tools don't actually solve their problems.
The solution isn't more tools or less governance. It's the right architecture: one that provides guardrails without creating barriers, and enables autonomy without sacrificing consistency.
The Self-Service Maturity Model
Organizations progress through predictable stages of analytics maturity:
┌──────────────────────────────────────────────────────────────────────────────┐
│ SELF-SERVICE MATURITY MODEL │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ Level 1: Chaos │
│ ────────────────── │
│ • Users have direct database access │
│ • Everyone writes their own SQL │
│ • Same metric calculated 10 different ways │
│ • "Which number is right?" is a constant question │
│ │
│ Level 2: Centralized Control │
│ ──────────────────────────── │
│ • Data team builds all reports │
│ • Request queue is weeks long │
│ • Users frustrated, workarounds emerge │
│ • Data team burns out │
│ │
│ Level 3: Curated Self-Service │
│ ────────────────────────────── │
│ • Semantic layer defines business concepts │
│ • Pre-built datasets for common use cases │
│ • Users explore within guardrails │
│ • Data team focuses on platform, not reports │
│ │
│ Level 4: Governed Autonomy │
│ ────────────────────────────── │
│ • Metrics store ensures consistency │
│ • Data contracts between producers/consumers │
│ • Automated quality and compliance checks │
│ • Business users contribute back to platform │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
Most organizations oscillate between Levels 1 and 2. The goal is reaching Level 3 or 4.
Architecture for Self-Service
The Semantic Layer
A semantic layer translates raw database tables into business concepts. It's the bridge between technical data models and business understanding.
┌──────────────────────────────────────────────────────────────────────────────┐
│ SEMANTIC LAYER ARCHITECTURE │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ BI TOOLS / CONSUMERS │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Tableau │ │ Looker │ │ Power BI │ │ Custom │ │ │
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │
│ └───────┼─────────────┼─────────────┼─────────────┼────────────────────┘ │
│ │ │ │ │ │
│ └─────────────┴──────┬──────┴─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ SEMANTIC LAYER │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Business Concepts │ │ │
│ │ │ • Customer = person who made at least one purchase │ │ │
│ │ │ • Revenue = sum of order_total where status = 'completed' │ │ │
│ │ │ • Active User = user with login in last 30 days │ │ │
│ │ └────────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Relationships │ │ │
│ │ │ • Customer → Orders (one-to-many) │ │ │
│ │ │ • Order → Products (many-to-many via line items) │ │ │
│ │ │ • Customer → Region (many-to-one) │ │ │
│ │ └────────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Access Control │ │ │
│ │ │ • Sales team sees their region's customers only │ │ │
│ │ │ • Finance sees all, including cost data │ │ │
│ │ │ • Marketing excluded from PII │ │ │
│ │ └────────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ PHYSICAL DATA LAYER │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ orders │ │customers │ │ products │ │ events │ │ │
│ │ │ (fact) │ │ (dim) │ │ (dim) │ │ (fact) │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
Implementing with dbt and Looker
# models/marts/core/customers.yml
# dbt model with semantic layer annotations
version: 2
models:
- name: dim_customers
description: "Customer dimension - one row per customer"
meta:
# Looker/BI integration
looker:
hidden: false
label: "Customers"
columns:
- name: customer_id
description: "Unique customer identifier"
tests:
- unique
- not_null
meta:
looker:
hidden: true
- name: full_name
description: "Customer's full name"
meta:
looker:
label: "Customer Name"
- name: email
description: "Customer's email address"
meta:
looker:
hidden: true # PII - hidden from most users
tags: ['pii']
- name: customer_segment
description: "Customer segmentation (Enterprise, SMB, Consumer)"
meta:
looker:
label: "Segment"
suggestions: ['Enterprise', 'SMB', 'Consumer']
- name: first_order_date
description: "Date of customer's first order"
meta:
looker:
label: "First Order"
timeframes: [date, week, month, quarter, year]
- name: total_lifetime_revenue
description: "Total revenue from this customer, all time"
meta:
looker:
label: "Lifetime Revenue"
value_format: "$#,##0.00"
type: sum
- name: is_active
description: "True if customer has ordered in last 90 days"
meta:
looker:
label: "Active Customer"
type: yesno
-- models/marts/core/dim_customers.sql
-- The actual transformation that materializes the semantic model
{{
config(
materialized='table',
tags=['core', 'daily']
)
}}
with customers as (
select * from {{ ref('stg_crm__customers') }}
),
orders as (
select * from {{ ref('fct_orders') }}
),
customer_orders as (
select
customer_id,
min(order_date) as first_order_date,
max(order_date) as most_recent_order_date,
count(distinct order_id) as order_count,
sum(order_total) as total_lifetime_revenue
from orders
group by 1
)
select
c.customer_id,
c.first_name || ' ' || c.last_name as full_name,
c.email,
c.customer_segment,
-- Derived attributes
co.first_order_date,
co.most_recent_order_date,
co.order_count,
co.total_lifetime_revenue,
-- Business logic embedded in model
case
when co.most_recent_order_date >= current_date - interval '90 days'
then true
else false
end as is_active,
case
when co.total_lifetime_revenue >= 10000 then 'High Value'
when co.total_lifetime_revenue >= 1000 then 'Medium Value'
else 'Low Value'
end as value_tier,
-- Metadata
current_timestamp as dbt_updated_at
from customers c
left join customer_orders co using (customer_id)
The Metrics Store
A metrics store centralizes metric definitions, ensuring consistency across tools and teams.
# metrics/revenue_metrics.yml
# dbt Metrics Layer definitions
version: 2
metrics:
- name: total_revenue
label: "Total Revenue"
description: >
Sum of all completed order revenue, excluding refunds.
This is the primary revenue metric used in financial reporting.
type: sum
sql: order_total
timestamp: order_date
time_grains: [day, week, month, quarter, year]
dimensions:
- customer_segment
- product_category
- sales_region
- channel
filters:
- field: order_status
operator: '='
value: "'completed'"
meta:
owner: finance-team
certified: true
certification_date: "2024-01-15"
- name: average_order_value
label: "Average Order Value (AOV)"
description: "Average revenue per order"
type: derived
sql: "{{ metric('total_revenue') }} / {{ metric('order_count') }}"
timestamp: order_date
time_grains: [day, week, month, quarter, year]
dimensions:
- customer_segment
- product_category
- channel
meta:
owner: analytics-team
certified: true
- name: customer_acquisition_cost
label: "Customer Acquisition Cost (CAC)"
description: "Marketing spend divided by new customers acquired"
type: derived
sql: "{{ metric('marketing_spend') }} / {{ metric('new_customers') }}"
timestamp: date
time_grains: [week, month, quarter]
dimensions:
- channel
- campaign
meta:
owner: marketing-team
notes: "Only includes trackable digital spend"
- name: monthly_recurring_revenue
label: "Monthly Recurring Revenue (MRR)"
description: "Contracted monthly revenue from active subscriptions"
type: sum
sql: monthly_amount
timestamp: month_start
time_grains: [month]
filters:
- field: subscription_status
operator: '='
value: "'active'"
dimensions:
- plan_tier
- customer_segment
meta:
owner: finance-team
certified: true
related_metrics:
- arr # Annual Recurring Revenue
- net_mrr_growth
Query Layer Implementation
# metrics_api.py
"""
Metrics API for self-service consumption.
Provides programmatic access to certified metrics.
"""
from typing import List, Dict, Any, Optional
from datetime import date
from enum import Enum
from dataclasses import dataclass
import pandas as pd
class TimeGrain(Enum):
DAY = "day"
WEEK = "week"
MONTH = "month"
QUARTER = "quarter"
YEAR = "year"
@dataclass
class MetricQuery:
"""Query specification for metrics."""
metric_names: List[str]
time_grain: TimeGrain
start_date: date
end_date: date
dimensions: List[str] = None
filters: Dict[str, Any] = None
class MetricsStore:
"""
Unified interface for querying metrics.
Abstracts the underlying implementation (dbt, Looker, custom).
"""
def __init__(self, connection, metrics_catalog: Dict):
self.connection = connection
self.catalog = metrics_catalog
def get_metric(self, name: str) -> Dict:
"""Get metric definition from catalog."""
if name not in self.catalog:
raise ValueError(f"Unknown metric: {name}")
return self.catalog[name]
def list_metrics(
self,
owner: str = None,
certified_only: bool = True
) -> List[Dict]:
"""List available metrics with optional filtering."""
metrics = list(self.catalog.values())
if certified_only:
metrics = [m for m in metrics if m.get('meta', {}).get('certified')]
if owner:
metrics = [m for m in metrics if m.get('meta', {}).get('owner') == owner]
return metrics
def query(self, query: MetricQuery) -> pd.DataFrame:
"""
Execute a metric query.
Builds SQL from metric definitions and executes.
"""
# Build SQL for each metric
metric_sqls = []
for metric_name in query.metric_names:
metric = self.get_metric(metric_name)
sql = self._build_metric_sql(metric, query)
metric_sqls.append(f"({sql}) as {metric_name}")
# Build time dimension
time_col = self._get_time_column(query.time_grain)
# Build dimension columns
dim_cols = ", ".join(query.dimensions) if query.dimensions else ""
dim_group = f", {dim_cols}" if dim_cols else ""
# Build WHERE clause
where_clauses = [
f"date >= '{query.start_date}'",
f"date <= '{query.end_date}'",
]
if query.filters:
for field, value in query.filters.items():
where_clauses.append(f"{field} = '{value}'")
where_sql = " AND ".join(where_clauses)
# Combine into final query
final_sql = f"""
SELECT
{time_col} as period,
{dim_cols + ',' if dim_cols else ''}
{', '.join(metric_sqls)}
FROM metrics_base
WHERE {where_sql}
GROUP BY {time_col}{dim_group}
ORDER BY period
"""
return pd.read_sql(final_sql, self.connection)
def _build_metric_sql(self, metric: Dict, query: MetricQuery) -> str:
"""Build SQL expression for a single metric."""
metric_type = metric['type']
sql_expr = metric['sql']
if metric_type == 'sum':
return f"SUM({sql_expr})"
elif metric_type == 'count':
return f"COUNT({sql_expr})"
elif metric_type == 'count_distinct':
return f"COUNT(DISTINCT {sql_expr})"
elif metric_type == 'average':
return f"AVG({sql_expr})"
elif metric_type == 'derived':
# Derived metrics reference other metrics
# This is simplified - real implementation would resolve references
return sql_expr
else:
raise ValueError(f"Unknown metric type: {metric_type}")
def _get_time_column(self, grain: TimeGrain) -> str:
"""Get SQL for time bucketing."""
if grain == TimeGrain.DAY:
return "DATE(date)"
elif grain == TimeGrain.WEEK:
return "DATE_TRUNC('week', date)"
elif grain == TimeGrain.MONTH:
return "DATE_TRUNC('month', date)"
elif grain == TimeGrain.QUARTER:
return "DATE_TRUNC('quarter', date)"
elif grain == TimeGrain.YEAR:
return "DATE_TRUNC('year', date)"
Data Access Patterns
Tiered Data Products
Not everyone needs the same level of access. Create tiers:
┌──────────────────────────────────────────────────────────────────────────────┐
│ DATA ACCESS TIERS │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ TIER 1: Dashboards & Reports │
│ ──────────────────────────────── │
│ Audience: All employees │
│ Access: Pre-built dashboards, scheduled reports │
│ Skills: None required │
│ Guardrails: Read-only, curated views │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Executive Dashboard │ Sales Scorecard │ Weekly Email Report │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ TIER 2: Guided Exploration │
│ ─────────────────────────── │
│ Audience: Business analysts, power users │
│ Access: Semantic layer, certified metrics │
│ Skills: Basic BI tool usage │
│ Guardrails: Pre-defined dimensions, certified metrics only │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Drag-and-drop analysis │ Custom filters │ Save & share views │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ TIER 3: SQL Access │
│ ───────────────────── │
│ Audience: Analysts, data scientists │
│ Access: Curated data marts (Gold layer) │
│ Skills: SQL proficiency │
│ Guardrails: Read-only, resource limits, sensitive data masked │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ SQL editor │ Query history │ Cost tracking │ Schema docs │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ TIER 4: Raw Data Access │
│ ─────────────────────────── │
│ Audience: Data engineers, advanced analysts │
│ Access: Full data lake (with PII controls) │
│ Skills: Advanced SQL, data modeling │
│ Guardrails: Approval required, audit logging, cost accountability │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Bronze/Silver layers │ Spark notebooks │ Cost allocation │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
Query Governance
# query_governance.py
"""
Query governance for self-service access.
Implements cost controls, resource limits, and audit logging.
"""
from typing import Dict, Any, Optional
from datetime import datetime, timedelta
from dataclasses import dataclass
import re
@dataclass
class QueryPolicy:
"""Policies applied to user queries."""
max_rows: int = 100000
max_scan_bytes: int = 10 * 1024 * 1024 * 1024 # 10 GB
timeout_seconds: int = 300
allowed_tables: list = None
denied_tables: list = None
require_filters: list = None # Columns that must have filters
@dataclass
class QueryRequest:
"""Incoming query request with context."""
sql: str
user_id: str
user_role: str
request_time: datetime
class QueryGovernor:
"""
Enforces query policies for self-service users.
Prevents runaway queries and unauthorized access.
"""
# Role-based policies
ROLE_POLICIES = {
'analyst': QueryPolicy(
max_rows=100000,
max_scan_bytes=10 * 1024**3,
timeout_seconds=300,
denied_tables=['raw_*', 'staging_*', '*_pii'],
),
'power_user': QueryPolicy(
max_rows=1000000,
max_scan_bytes=50 * 1024**3,
timeout_seconds=600,
denied_tables=['raw_*', '*_pii'],
),
'data_scientist': QueryPolicy(
max_rows=10000000,
max_scan_bytes=100 * 1024**3,
timeout_seconds=1800,
denied_tables=['*_pii'],
),
}
def __init__(self, query_engine, audit_logger):
self.engine = query_engine
self.audit = audit_logger
def execute_query(self, request: QueryRequest) -> Dict[str, Any]:
"""
Execute query with governance controls.
"""
policy = self._get_policy(request.user_role)
# Pre-execution checks
validation_result = self._validate_query(request.sql, policy)
if not validation_result['allowed']:
self.audit.log_denied_query(request, validation_result['reason'])
raise PermissionError(validation_result['reason'])
# Estimate cost
estimate = self._estimate_query_cost(request.sql)
if estimate['bytes_scanned'] > policy.max_scan_bytes:
self.audit.log_denied_query(request, "Exceeds scan limit")
raise ResourceError(
f"Query would scan {estimate['bytes_scanned'] / 1024**3:.1f} GB, "
f"limit is {policy.max_scan_bytes / 1024**3:.1f} GB"
)
# Execute with limits
try:
result = self.engine.execute(
request.sql,
timeout=policy.timeout_seconds,
max_rows=policy.max_rows,
)
self.audit.log_successful_query(
request,
rows_returned=len(result),
bytes_scanned=estimate['bytes_scanned'],
)
return {
'data': result,
'metadata': {
'rows_returned': len(result),
'bytes_scanned': estimate['bytes_scanned'],
'execution_time_ms': result.execution_time,
}
}
except TimeoutError:
self.audit.log_timeout_query(request)
raise
def _validate_query(self, sql: str, policy: QueryPolicy) -> Dict[str, Any]:
"""Validate query against policy rules."""
# Extract referenced tables
tables = self._extract_tables(sql)
# Check denied tables
if policy.denied_tables:
for table in tables:
for pattern in policy.denied_tables:
if self._matches_pattern(table, pattern):
return {
'allowed': False,
'reason': f"Access to table '{table}' is not allowed"
}
# Check required filters
if policy.require_filters:
for required_filter in policy.require_filters:
if not self._has_filter(sql, required_filter):
return {
'allowed': False,
'reason': f"Query must include filter on '{required_filter}'"
}
# Check for dangerous operations
dangerous_patterns = [
r'\bDROP\b',
r'\bDELETE\b',
r'\bTRUNCATE\b',
r'\bINSERT\b',
r'\bUPDATE\b',
r'\bCREATE\b',
]
for pattern in dangerous_patterns:
if re.search(pattern, sql, re.IGNORECASE):
return {
'allowed': False,
'reason': "Write operations are not allowed"
}
return {'allowed': True}
def _extract_tables(self, sql: str) -> list:
"""Extract table names from SQL query."""
# Simplified - production would use SQL parser
pattern = r'\bFROM\s+([a-zA-Z_][a-zA-Z0-9_.]*)'
matches = re.findall(pattern, sql, re.IGNORECASE)
return matches
def _matches_pattern(self, table: str, pattern: str) -> bool:
"""Check if table matches glob pattern."""
import fnmatch
return fnmatch.fnmatch(table.lower(), pattern.lower())
def _has_filter(self, sql: str, column: str) -> bool:
"""Check if query has filter on specified column."""
pattern = rf'\bWHERE\b.*\b{column}\b.*='
return bool(re.search(pattern, sql, re.IGNORECASE))
def _get_policy(self, role: str) -> QueryPolicy:
"""Get policy for user role."""
return self.ROLE_POLICIES.get(role, self.ROLE_POLICIES['analyst'])
def _estimate_query_cost(self, sql: str) -> Dict[str, int]:
"""Estimate query cost before execution."""
return self.engine.dry_run(sql)
Organizational Change Management
Technology is only half the solution. Self-service requires organizational change.
The Self-Service Enablement Team
┌──────────────────────────────────────────────────────────────────────────────┐
│ SELF-SERVICE ENABLEMENT TEAM │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ ROLE: Analytics Engineer │
│ ───────────────────────────── │
│ • Builds and maintains semantic layer │
│ • Creates certified metrics and dimensions │
│ • Documents data models for business users │
│ • Partners with data engineers on pipeline requirements │
│ │
│ ROLE: Data Advocate / Champion │
│ ───────────────────────────────── │
│ • Embedded in business teams │
│ • Translates business needs to data requirements │
│ • Trains users on self-service tools │
│ • Gathers feedback, identifies pain points │
│ │
│ ROLE: Data Governance Lead │
│ ────────────────────────────── │
│ • Defines data access policies │
│ • Manages metric certification process │
│ • Monitors compliance and audit logs │
│ • Arbitrates metric definition disputes │
│ │
│ ROLE: Self-Service Platform Owner │
│ ───────────────────────────────── │
│ • Owns tool selection and integration │
│ • Manages vendor relationships │
│ • Tracks adoption metrics │
│ • Prioritizes platform improvements │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
Adoption Playbook
Phase 1: Pilot (Weeks 1-4)
- Select 5-10 power users from one department
- Provide white-glove support and training
- Document common questions and pain points
- Iterate on data models based on feedback
Phase 2: Department Rollout (Weeks 5-12)
- Expand to full department
- Establish office hours for support
- Create self-service documentation
- Train department "data champions"
Phase 3: Enterprise Rollout (Weeks 13-24)
- Open to additional departments
- Scale training through e-learning
- Establish community of practice
- Measure and report adoption metrics
Measuring Success
-- self_service_metrics.sql
-- Metrics to track self-service adoption and effectiveness
-- Adoption metrics
SELECT
date_trunc('week', query_time) as week,
COUNT(DISTINCT user_id) as active_users,
COUNT(*) as total_queries,
COUNT(DISTINCT user_id) FILTER (
WHERE user_role = 'business_user'
) as self_service_users,
AVG(query_duration_seconds) as avg_query_time
FROM query_audit_log
WHERE query_time >= CURRENT_DATE - INTERVAL '90 days'
GROUP BY 1
ORDER BY 1;
-- Self-service vs request-based
SELECT
date_trunc('month', request_date) as month,
COUNT(*) FILTER (WHERE request_type = 'ad_hoc') as ad_hoc_requests,
COUNT(*) FILTER (WHERE request_type = 'self_service') as self_service_queries,
ROUND(
100.0 * COUNT(*) FILTER (WHERE request_type = 'self_service')
/ COUNT(*),
1
) as self_service_pct
FROM analytics_requests
GROUP BY 1
ORDER BY 1;
-- Time to insight
SELECT
request_type,
AVG(time_to_answer_hours) as avg_hours,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY time_to_answer_hours) as median_hours,
PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY time_to_answer_hours) as p90_hours
FROM analytics_requests
WHERE request_date >= CURRENT_DATE - INTERVAL '90 days'
GROUP BY 1;
-- Data quality satisfaction
SELECT
date_trunc('month', survey_date) as month,
AVG(trust_in_data_score) as avg_trust_score,
AVG(ease_of_use_score) as avg_usability_score,
COUNT(DISTINCT respondent_id) as responses
FROM self_service_surveys
GROUP BY 1
ORDER BY 1;
Key Takeaways
-
Self-service requires governance: Unconstrained access creates chaos. Governance enables autonomy by providing guardrails.
-
Semantic layers are essential: They translate raw data into business concepts, ensuring consistent understanding across tools and users.
-
Metrics stores prevent "which number is right?": Centralized metric definitions ensure everyone calculates KPIs the same way.
-
Tier your access: Not everyone needs SQL access. Most users are well-served by dashboards and guided exploration.
-
Change management matters: Technology alone won't create self-service adoption. Invest in training, support, and organizational change.
-
Measure what matters: Track adoption, time-to-insight, and user satisfaction—not just query counts.
References
- Kimball, R., & Ross, M. (2013). The Data Warehouse Toolkit (3rd ed.). Wiley.
- dbt Labs. (2023). "The Semantic Layer: A New Standard for Business Intelligence."
- Looker Documentation: LookML Best Practices
- Atlan. (2023). "Building a Modern Data Stack with Self-Service Analytics."
- Thoughtworks Technology Radar: Metrics Stores
- DataOps Cookbook: Enabling Self-Service Analytics at Scale
Key Takeaways
- ✓Self-service doesn't mean 'no governance'—it means governance that enables rather than restricts
- ✓A semantic layer is essential: it provides consistent business definitions regardless of tool choice
- ✓Metrics stores centralize metric definitions, ensuring everyone calculates KPIs the same way
- ✓Start with power users, not everyone—build success stories before scaling
- ✓Change management is as important as technology—invest in training and support



