Crosshire
← Handbook
FoundationLayers · contracts · naming

01Architecture

This route decides three things that are painful to reverse once tables exist: what each layer is allowed to contain, what objects are called, and how environments are separated. None of them is technically difficult. All of them are expensive to change in month six, because by then there are dashboards pointed at the names.

Stand 29.07.2026

The layer contract

Bronze, silver and gold are the least controversial diagram in data engineering and the most frequently misread. The contract below is what stops the diagram from being decoration.

#Medallion is not a data model

What
Bronze, silver and gold describe how refined data is. They say nothing about how it is structured. The three layers carry no information about grain, keys, conformance or history — which means adopting medallion has not yet modelled anything.
The star schema is a separate decision, made in gold and only in gold. Watch what happens to one business concept as it moves through the layers — the name barely changes and the grain changes completely:
text
One concept, three layers, three different grains
bronze.sap_vbap        one row per order-item CHANGE EVENT (CDC)
                       keys: whatever SAP sent
                       history: everything, forever, append-only

silver.sales_order_line   one row per ORDER LINE, current state
                       keys: order_id + line_number (business keys)
                       history: SCD1 — overwritten

gold.fct_order_line    one row per ORDER LINE, analysis-ready
                       keys: order_line_sk (surrogate)
                       + customer_sk, product_sk, date_sk, region_sk
                       history: none itself; it points at SCD2 dimensions
                       currency: converted, amount_eur materialised
Three layers, three grains, three key strategies. Deciding bronze → silver → gold decided none of that.
Why here
The failure this prevents is the most common one in warehouse projects that adopt Databricks quickly: gold tables that are silver tables with a nicer prefix. They have no surrogate keys, no conformed dimensions, and a fact grain inherited from whatever the source happened to deliver.
Nothing is visibly wrong until the first question that spans two sources. Someone asks for revenue by customer segment across SAP and the webshop, and there is no conformed customer to group by — because conformance was never a step, it was assumed to be a side effect of the arrow between two boxes.
Doing it
  • Write the grain sentence — 'one row per …' — before you create any table. If it takes more than one sentence, the grain is wrong.
  • Put that sentence in the table COMMENT, deployed in DDL, not typed into the UI.
  • Give every gold table surrogate keys; leave business keys visible alongside them.
  • Conform dimensions in gold. Silver stays source-shaped and honest.
  • Enforce the read direction: gold reads silver, silver reads bronze, nothing skips.
Embedding it
  • In every review, ask one question: 'what is one row of this table?' A team that cannot answer in a sentence has not modelled it, and now knows that.
  • Make them draw the star on a whiteboard before any DDL is written. Ten minutes at a whiteboard is cheaper than a migration.
  • Have the team own the layer-contract page on their own wiki. If it lives in your handover deck, it dies at handover.
  • When you find a gold table with no _sk column, do not fix it yourself — show them how you spotted it in ten seconds.
Defaults
  • One sentence of grain per table, in the COMMENT, deployed as code.
  • Surrogate keys are a gold concern. Silver keeps business keys.
  • gold reads only silver; silver reads only bronze. No diagonal arrows.
  • If a gold table is a renamed silver table, delete it and let BI read silver directly — an honest shortcut beats a dishonest layer.
Gotchas
  • Gold tables that are silver with a prefix. You find them in seconds: no column ends in _sk. If the whole layer looks like that, the project has a medallion diagram and no data model.
  • Skipping silver because 'bronze is clean enough'. It is, for exactly one source. When the webshop arrives there is nowhere to conform customer identity, and the fix is now a rewrite of everything downstream instead of a new silver table.
  • Inventing a fourth layer — platinum, or an extra mart schema — is almost always a symptom that gold was never modelled, and the new layer is where the modelling is quietly being done instead.
  • Letting a dashboard read silver 'just for now'. It becomes permanent, and it pins silver's shape forever: you can no longer refactor silver without breaking a report nobody told you about.

#The contract itself

What
One table, agreed once, that makes code review mechanical instead of argumentative. Every row of it answers a question that otherwise gets re-litigated in every pull request.
What each layer may contain
bronzesilvergold
May enterraw source output, unmodifiedbronze onlysilver only
Grainas delivered by the sourceone row per business entity or eventdeclared per table; facts at the finest useful grain
Transformations allowednone — add technical columns onlytyping, cleansing, deduplication, conformance, historisationsurrogate keys, joins to dimensions, aggregation, currency conversion
Historyeverything, append-only, never deletedSCD1 or SCD2 per table, declareddimensions carry SCD2 intervals; facts are insert/upsert
Who reads itengineers onlyengineers; analysts by exceptioneveryone — BI, Genie, metric views
Forbiddenbusiness logic of any kindsurrogate keys, aggregationreading bronze; source-specific column names
Bronze's one permitted modification is technical metadata. Every bronze table in this warehouse carries the same three columns, which is what makes 'when did this row arrive and from which file' answerable without archaeology:
sql
The bronze technical columns — identical on every bronze table
_tech_loaded_at    TIMESTAMP   -- when this row landed
_tech_source_file  STRING      -- which file or extract it came from
_tech_batch_id     STRING      -- which run wrote it
Why here
Without a written contract, 'should this go in silver or gold?' is settled by whoever argues longest, and the answer differs per table. Six months later the layers no longer mean anything, and a new joiner has to read the code of every table to know what it is.
With it, review becomes a lookup. This gold table reads bronze — the contract forbids it, here is the row. No opinion required, which is exactly what you want a junior reviewer to be able to say to a senior engineer.
Doing it
  • Agree the table in one session, in week one, before there are tables to argue about.
  • Put it in the repository next to the code, not in a slide deck.
  • Add the three technical columns to bronze in the ingestion framework, so no table can forget them.
  • Add a CI check that fails a gold model importing from bronze — the contract that is only prose gets violated within a month.
Embedding it
  • Have the team fill in the empty table themselves; you facilitate and push back. A contract they wrote is one they enforce.
  • First time someone cites the contract in a review instead of citing you, the coaching worked — say so out loud.
  • Review the contract once at the mid-point. If nothing needed changing, they probably are not using it.
Defaults
  • The contract lives in the repo and changes by pull request like anything else.
  • Technical columns are added by the framework, never hand-written per table.
  • Encode the read-direction rule as a CI check, not as a paragraph.
  • Exceptions are allowed but must be written down with an expiry date.
Gotchas
  • A contract with no enforcement is a wish. The read-direction rule in particular is violated within weeks unless CI checks it, because reading bronze directly is always the fastest way to close today's ticket.
  • Making the contract too detailed. If it specifies naming for every column type it stops being read at all. One page, six rows.
  • Writing 'analysts may not read silver' and then giving analysts SELECT on the whole catalog. The contract and the grants must say the same thing — see the governance routes.

Naming and identifiers

Names are the cheapest documentation available and the only kind that is always up to date. Two rules — a prefix that carries the contract, and an identifier standard — cover almost everything.

#Prefixes that carry the contract

What
The prefix tells a reader what the object is and how to treat it, before they open it. This is the full set for this warehouse — deliberately small, because a prefix scheme nobody can recite is a prefix scheme nobody follows.
PrefixMeansExampleRule
dim_dimensiongold.dim_customerhas a _sk; may be SCD2
fct_factgold.fct_order_linedeclared grain; FKs to dimensions only
agg_aggregategold.agg_revenue_monthderived from a fct_; never a source
brg_bridgegold.brg_product_groupresolves many-to-many; no measures
stg_stagingstg_customer_deltatransient; dropped after the run
mv_metric viewgold.mv_salessemantic layer; read with MEASURE()
_tech_technical column_tech_valid_fromnever business meaning; never exposed to BI
Why here
A reader who sees gold.agg_revenue_month knows immediately that it is derived, that it must not be a source for anything else, and that if the number is wrong the bug is upstream in fct_order_line. That is three facts obtained from a name, and it is why a report writer stops asking you where numbers come from.
The _tech_ prefix earns its place separately: it is the single filter that separates plumbing from business meaning. Everything a BI tool should never see starts with it.
Doing it
  • Apply the prefix at creation. Renaming a table that a dashboard reads is a migration, not a tidy-up.
  • Filter _tech_ columns out of the gold views exposed to BI, mechanically rather than by hand-listing columns.
  • Use stg_ for anything transient and actually drop it — a staging table that survives the run is a table someone will start depending on.
Embedding it
  • Ask the team to name three new tables at the whiteboard before they build them; disagreements surface the modelling question hiding underneath.
  • Add prefixes to the review checklist so it is caught at review, when renaming is free.
  • When someone proposes a new prefix, make them argue for it. Usually the object belongs to an existing category and the urge to invent a prefix is really an unresolved modelling question.
Defaults
  • Seven prefixes, no more. A scheme people can recite is a scheme people apply.
  • The prefix is part of the contract — CI can check that anything named fct_ has a declared grain comment.
  • Never prefix with the source system. gold.sap_customer means the conformance never happened.
Gotchas
  • mv_ is a genuine collision on this platform: Databricks has materialized views AND Unity Catalog metric views, and they are different objects with different refresh semantics and different costs. Pick one meaning and write it down. This handbook uses mv_ for metric views and spells out 'materialized view' in full every time it means the other thing.
  • Leaving a fact table unprefixed 'because it's obvious'. Then the aggregate is indistinguishable from the fact in an autocomplete list, and someone builds a report on the aggregate thinking it is the detail.
  • Prefixing by source (sap_, shop_) in gold. It reads as tidy and it means every downstream consumer has to know which system a customer came from — which is precisely the knowledge gold exists to remove.

#Identifier language and the ASCII rule

What
One standard, applied without exception: identifiers are English, lower_snake_case, ASCII only. Business terminology in the local language belongs in comments, in the metric view's display names, and in the glossary — not in column names.
sql
The rule in practice
-- yes
CREATE TABLE gold.dim_customer (
  customer_sk       STRING  COMMENT 'Surrogate key, sha2 of customer_id',
  customer_segment  STRING  COMMENT 'Kundengruppe: A | B | C',
  postal_code       STRING  COMMENT 'PLZ'
)
COMMENT 'One row per customer per validity interval (SCD2).';

-- no
CREATE TABLE gold.dim_kunde (
  kunden_schlüssel  STRING,   -- umlaut in an identifier
  Kundengruppe      STRING,   -- mixed case
  PLZ               STRING    -- upper case
);
Delta and Unity Catalog will accept kunden_schlüssel. That is not the same as it being safe.
Why here
The precise failure mode is Unicode normalisation, and it is nastier than it sounds. ö can be encoded as one code point (NFC, U+00F6) or as o plus a combining diaeresis (NFD, U+006F U+0308). They render identically in every editor and terminal. macOS filesystems historically hand back NFD; Windows and most Linux tooling produce NFC.
So a column created from a macOS laptop and referenced from a Windows CI runner can be two different strings that look the same, and the error message is column not found naming a column you can see in the table. Engineers lose a day to this, and they lose it while doubting their own eyes.
The second reason is duller and matters more often: a mixed international team cannot type these names quickly, so they copy-paste them, so they stop reading them.
Doing it
  • Set the rule in week one and enforce it in CI with a regex over DDL — ^[a-z][a-z0-9_]*$ is the whole check.
  • Put the local-language term in the COMMENT, where it helps the business reader and breaks nothing.
  • Use the metric view's display metadata for anything a BI user sees, so the German label and the ASCII identifier can coexist.
  • Keep SAP's own table names (KNA1, VBAK, VBAP) in bronze — those are the literal source names and renaming them destroys traceability.
Embedding it
  • Show the NFC/NFD failure once, live, rather than asserting it — paste two visually identical column names into a query and let it fail. Nobody forgets it afterwards.
  • Expect pushback that English names distance the business from the model. Answer it concretely: the business sees the metric view and the comments, not the DDL.
  • Let the team write the regex check themselves. It is a ten-line CI job and it teaches that a standard without a check is a preference.
Defaults
  • ^[a-z][a-z0-9_]*$ for every identifier, checked in CI.
  • Local-language business terms live in COMMENT and in metric-view labels.
  • Source table names preserved verbatim in bronze; conformed English names from silver onward.
  • No abbreviations that are not already universal in the domain — cust_seg costs more in confusion than customer_segment costs in typing.
Gotchas
  • Unicode normalisation, as above: two identifiers that render identically, compare unequal, and produce a 'column not found' error naming a column you can plainly see.
  • Reserved words — but check which ones, because the intuitive answer is wrong here. Databricks does not formally disallow any literal as an identifier, and the ANSI keyword list is explicitly informational and unenforced. What actually needs backticks is a short list of 17 words used as a table alias: ANTI, CROSS, EXCEPT, FULL, INNER, INTERSECT, JOIN, LATERAL, LEFT, MASK, MINUS, NATURAL, ON, RIGHT, SEMI, UNION, USING. Alias a subquery left and the parser reads a join; the error names the join syntax, not your alias, so the fix is not where the message points.
  • Assuming ANSI reserved words are unusable. They are usable, which is a trap in the other direction: order is a legal Databricks table name. This handbook still calls the table sales_orderFROM order beside ORDER BY is genuinely hard to read, and engines you may federate to do reserve it — but that is a readability and portability argument, not a platform constraint. Anyone who tells a client ORDER is reserved on Databricks will be corrected live.
  • Mixed case identifiers. Delta is case-insensitive for resolution but case-PRESERVING in the metadata, so Kundengruppe and kundengruppe resolve to the same column while tools that compare schema strings report a difference. Schema-drift monitors then alarm on a change that did not happen.
  • Renaming identifiers after BI is connected. The rename is one statement; the coordination with every report, every metric view and every downstream extract is a fortnight.

Environments and catalogs

Three environments, three catalogs, and one binding rule that makes 'could dev read production?' answerable with a yes or a no rather than with a discussion.

#Catalog per environment

What
Each environment is its own Unity Catalog catalog. The layers are schemas inside it, so a fully qualified name differs between environments in exactly one place — the first segment.
text
The topology
dwh_dev  /  dwh_test  /  dwh_prod
  |-- bronze      raw, append-only, technical metadata added
  |-- silver      cleansed, conformed, historised
  |-- gold        star schema + aggregates + metric views
  |-- fixtures    seeded test data          (dwh_test only)
  '-- ci          Volume for JUnit reports  (dwh_test only)

per-CI-run isolation:  dwh_test.run_<job_run_id>
Because only the first segment varies, the catalog is a deployment parameter and never a literal in code:
python
src/dwh/jobs/silver_customer.py — the catalog arrives from configuration
def run(spark, catalog: str) -> int:
    """Rebuild silver.customer from bronze. Returns rows written."""
    source = f"{catalog}.bronze.sap_kna1"
    target = f"{catalog}.silver.customer"

    df = clean_customer(spark.read.table(source))
    df.write.mode("overwrite").saveAsTable(target)
    return df.count()
Every CI run gets its own schema — dwh_test.run_<job_run_id> — so two pipelines running concurrently on two branches cannot see each other's tables. Without that, the second developer to push gets a failure caused by the first developer's data, and spends the afternoon debugging their own correct code.
Why here
The alternative most teams reach for first is one catalog with the environment in the schema name — dwh.prod_gold, dwh.dev_gold. It looks equivalent and is not, for one reason: grants are given at catalog and schema level. In the single-catalog design, every grant is one careless wildcard away from spanning environments, and there is no structural boundary to stop it.
With a catalog per environment you get workspace-catalog binding: the development workspace can be bound so that dwh_prod is not merely ungranted but not reachable. 'Could someone in dev read production?' becomes a configuration you can show an auditor, rather than a review of every grant ever issued.
Doing it
  • Create three catalogs before any table exists. Retrofitting this means moving every table and re-pointing every report.
  • Bind each workspace to the catalogs it may see; do not rely on grants alone.
  • Pass the catalog in as a bundle variable per target — never a literal, never an environment sniff at runtime.
  • Give each CI run its own schema and drop it at the end of the run, in a step that runs even when the tests failed.
Embedding it
  • Ask the team to demonstrate that dev cannot read prod, rather than to assert it. The demonstration is the artefact — keep it, it is your access-review evidence later.
  • Have them find the hardcoded catalog names themselves with a grep. There are always some, and finding them is more instructive than being told.
  • Make the CI-schema cleanup step the team's, and let it fail once. A leaked schema per run is visible within a week and teaches the lesson permanently.
Defaults
  • Catalog per environment; layers as schemas; one metastore per region.
  • Workspace-catalog binding on top of grants, not instead of them.
  • Catalog as a deployment parameter, resolved at deploy time.
  • Production objects owned by groups, never by individuals — covered in the Unity Catalog route.
  • CI schemas are per-run and dropped in an always-runs step.
Gotchas
  • Hardcoded catalog names. They work in every environment the author tested and fail in the one they did not — and the failure mode is not an error, it is a dev job quietly writing into dwh_prod because the string was right there in the notebook.
  • Retrofitting catalog-per-environment after go-live. Every table moves, every grant is reissued, every report is re-pointed, and there is no partial rollout. Cost this at the start, when it is free.
  • CI runs sharing one schema. Two branches collide, the failure looks like a data bug, and the developer debugs their own correct transformation for an afternoon before anyone suspects the harness.
  • Assuming binding is inherited. Binding is configured per workspace; a new workspace created later starts unbound, which is the moment the boundary you demonstrated in month one silently stops being true.

Straight to the documentation

The generic platform material lives in the official docs and is not restated here. These are the pages worth having open while you work through this route.

Platform facts on this page verified 29.07.2026 against the official documentation. Volatile claims are anchored to the currency register.