Skip to content

Course 2 · Module 1 — Product boundaries

You finished Course 1 with the core loop cold: declare a table, quench it, preview the diff, converge. But that was one package against one database. Real life isn’t that tidy. You’ve got a shared lookup table every server needs, a role-specific table only the app servers want, and a pile of DBA-owned maintenance objects that have no business living in the app’s package. Cram it all into one blueprint and you’ve built a deploy that targets everything and owns nothing cleanly. Let’s not. Let’s draw the boundaries first.

A product is one Product.json — the jig that says what a deployment targets and whether it’s even allowed to run here. It’s the maker’s mark on the work: a name, the templates it deploys, and the guardrails that decide whether the forge fires at all.

Two of those guardrails matter today, and they’re the real ones:

  • ValidationScript answers “am I deploying to the correct server?” It’s a SQL expression run once, up front — return a truthy value or the whole quench aborts before touching a thing. Right server, right infrastructure, right environment marker. This is the gate.
  • BaselineValidationScript answers “is this environment at the expected state?” It runs after server validation, before any template work — so a v2.3 package can refuse to run against a database already at v2.5 instead of quietly rolling you backward.

There’s a third property people reach for by mistake: MinimumVersion. Per the schema-packages reference, it’s currently metadata only — displayed in tooling, not enforced at deploy time. It documents intent; it doesn’t hold the gate. When you need a real version floor, you write it into ValidationScript. Don’t lean on MinimumVersion to stop a deploy — it won’t.

Here’s the subtle part, and it’s worth slowing down for. ValidationScript runs against the engine’s admin/init databasemaster on SQL Server, postgres on PostgreSQL, information_schema on MySQL — before the template selects your target database. So a guardrail has to be a server-level question.

The trap: writing current_database() = 'learn' to mean “only deploy to the learn database.” It’ll never pass — the script is looking at the admin DB, not learn, so it sees the wrong name and wrongly aborts every time. The fix is to ask a server-level question instead: does the target database exist on this server? That’s exactly what the lab’s guardrails do — a catalog-existence check, one per engine:

-- SQL Server
SELECT CASE WHEN DB_ID('learn') IS NOT NULL THEN 1 ELSE 0 END
-- PostgreSQL
SELECT CASE WHEN EXISTS (SELECT 1 FROM pg_database WHERE datname = 'learn') THEN 1 ELSE 0 END
-- MySQL
SELECT CASE WHEN EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'learn') THEN 1 ELSE 0 END

Server-level question, server-level answer. That’s the shape every guardrail needs.

Now the boundary itself. The instinct with one package is to pile everything in. The discipline is the opposite: one product per server role, and never copy the shared parts.

Three products model the whole pattern:

  • A common product — the shared things every server needs. Static lookup data, reference rows, audit scaffolding. Defined once, deployed everywhere.
  • A product per server type — the role-specific schema. An OLTP app role gets its app tables; a reporting or replication role gets its own. Nothing shared gets copied in.
  • An adjacent DBA-managed product — maintenance jobs, monitoring objects, admin-only tables. Those are the DBA’s concern on the DBA’s schedule. They get their own product, not a corner of the app’s.

Define once, deploy everywhere, keep roles apart. That’s the whole idea.

Three products along server roles: the common product deploys to every server, a per-role product deploys only to its role, and an adjacent DBA-managed product stays separate. The shared lookup table is declared once in the common product and never duplicated.

The payoff is the part you don’t see: no duplication. If that shared Currency table got copied into the app product and the reporting product and the DBA product, you’d own three definitions of one table. Edit one, forget the others, and they drift — and drift is the exact thing you came to SchemaSmith to kill. Declare it once in the common product and every role gets the same true copy.

The lab ships two of the three products per engine — common (product CommonLookups, with a Currency lookup table) and appserver (product OltpApp, with a SalesOrder table). Both deploy into the same learn database, each by its own SchemaQuench run. The DBA product is the same move a third time; the lab leaves it as the exercise.

Deploy the common product first:

Terminal window
schemaquench --ConfigFile:deploy.settings.json
Begin Quench of CommonLookups
Validate Server
[localhost,11433].[learn] Adding new table [dbo].[Currency]
Creating constraint [dbo].[Currency].[PK_Currency]
[localhost,11433].[learn] Successfully Quenched
Completed quench of CommonLookups

Validate Server is the guardrail firing — ValidationScript confirmed learn exists on this server before a single object got built. Then the shared lookup table goes in. (PostgreSQL says Create new table public.Currency; MySQL says Create table `Currency` — same dialect tweaks you met in Course 1.)

Now the role product, into the same database:

Terminal window
schemaquench --ConfigFile:deploy.settings.json
Begin Quench of OltpApp
[localhost,11433].[learn] Adding new table [dbo].[SalesOrder]
Creating constraint [dbo].[SalesOrder].[PK_SalesOrder]
Successfully Quenched
Completed quench of OltpApp

Here’s the proof the boundary holds: deploying OltpApp added SalesOrder and left Currency completely alone. SchemaQuench only manages what each product declares. OltpApp never names Currency, so it never touches it. Two products, one database, one shared table — declared in exactly one place.

Re-run either one and you get the no-op: the declared state already matches, so the difference is zero and nothing new gets created. (Honest note: progress labels may still print on a clean re-run — you might see a constraint-check label line — even though no DDL runs and no duplicate is made. Read it as “nothing changed,” not “zero lines.”)

Point a product at the wrong server and the gate does its job. Flip a ValidationScript to look for a database that isn’t there and run it:

Begin Quench of CommonLookups
Validate Server
System.Exception: Invalid server for this product

Exit code 3, and nothing in the database moved. The forge stayed cold because the metal was wrong. That’s the guardrail you’d aim at a production hostname, a known environment marker, or a baseline version to keep a package from ever quenching somewhere it shouldn’t.

Check yourself: You have one lookup-data template every server needs and another that's only for replication servers. How do you avoid duplicating the shared one?

Put the shared template in a common product deployed everywhere; put the role-specific template in a product per server type — so the shared one is defined once and deployed to all, never copied into each role.


Think of it as a rack of jigs, not one oversized blueprint. The common jig stamps every piece; each role jig shapes only its own; the DBA’s jig hangs on its own hook. One shared part, cut once, fitted everywhere — never re-cut three times to drift three ways. That’s a deployment you can reason about.

Got a boundary you’re not sure where to draw — does it belong in the common product or a role product? Email me at forgebarrett@schemasmith.com — I read every one.

Next up: Course 2 · Module 2 — Templates, where one product fans out across every database or schema a definition applies to, so a single quench run shapes a whole fleet at once.

Until then, may every product own its own corner of the fire and no two ever forge the same part twice.

— Forge