The Three-Tier Template Model
Last reviewed · content updated
IntermediateWhat you'll learn
~20 min- Structure a template library in three tiers: atomic steps, composing stages, copy-me starters
- Use stepList injection so stage templates own scaffolding while consumers own steps
- Consume a central template repo with pinned references
From forty pipelines to one product
By the fifth team, Meridian’s platform crew has read forty YAML files that are 80% identical and 20% divergent in ways nobody chose deliberately. The fix is the module title: pipelines become a product — a template library teams consume — and the architecture that’s proven out in a real software factory has exactly three tiers:
TIER 1 STEP TEMPLATES ~dozens of atomic, single-responsibility units, (steps/*.yml) grouped by concern: build, security-scan, deploy, health-check, package, evidence. Each does ONE thing and exposes typed parameters.TIER 2 STAGE TEMPLATES the composition layer: own the stage scaffolding (stages/*.yml) (timing, wrapping, always-appended health/report steps) and SPLICE IN consumer-provided steps.TIER 3 STARTER PIPELINES extends targets that LIVE IN THE TEMPLATE REPO (starters/*.yml) (so 2.3's required-template check can see them); the copy-me part is the ~20-line consumer file that extends one and stays ~20 lines forever.The mechanism that makes tier 2 work: stepList injection
The elegant move — worth understanding deeply because it’s the template-method pattern rendered in YAML — is the stage template accepting a list of steps as a parameter and splicing it into scaffolding it owns:
# stages/deploy-stage.yml (tier 2)parameters: - name: environmentName type: string - name: deploySteps type: stepList # <- the injection point default: []
stages: - stage: deploy_${{ parameters.environmentName }} jobs: - deployment: deploy environment: ${{ parameters.environmentName }} strategy: runOnce: deploy: steps: - template: ../steps/record-start.yml # scaffold: owned - ${{ each step in parameters.deploySteps }}: # consumer steps - - ${{ step }} # see governance note - template: ../steps/health-report.yml # scaffold: ALWAYS # appended, not optionalRead the division of authority: the stage owns the frame — timing capture, the environment binding, and the health-report step that every deployment emits whether the consumer remembered it or not — while the consumer owns only the middle: which steps actually deploy their thing. A team cannot forget the health report, because forgetting isn’t expressible. That’s the same design instinct as compliance-baked-into-modules (coming in Module 3) and it’s the whole reason tier 2 exists: scaffolding you can’t opt out of, flexibility exactly where teams differ.
Consumption side, a starter pipeline stays honest-sized:
# a product team's azure-pipelines.yml (from a tier-3 starter)resources: repositories: - repository: templates type: git name: platform/pipeline-templates ref: refs/tags/v2.4.0 # PINNED - next lesson explains why
extends: # governance hook - also next lesson template: starters/web-app.yml@templates parameters: environments: [test, prod] deploySteps: - script: ./deploy.sh $(target)Two details to notice now and study next lesson: the template repo is consumed by pinned tag (@templates + ref:), so a template change never silently rewrites forty teams’ pipelines; and the consumer extends a starter rather than including fragments — the hook that governance will hang from.
Prompt first
Build tier 1 and tier 2 of our template library:- Step templates: npm-build, container-build, security-scan (stub for now - Module 3 fills it), deploy-webapp, health-check, record-timing. One responsibility each, typed parameters, no environment assumptions.- One stage template: build-stage (owns timing + artifact publish, injects buildSteps) and one deploy-stage as sketched above (owns environment binding + always-appended health-report, injects deploySteps).- One tier-3 starter: web-app.yml wiring build + deploy-to-two-environments, with TODO markers where a team must choose.Then convert Meridian's crew-scheduling pipeline from Lesson 2.1 to consumethe starter - the diff should DELETE most of that file.The conversion diff is the proof of the product: a forty-line bespoke pipeline collapsing to a twenty-line starter consumption means the divergence that remains is chosen, not accidental.
The GitHub equivalents are reusable workflows (callable whole-workflow units via uses:) and composite actions (step bundles). The honest difference: there’s no stepList-style injection — a reusable workflow can’t splice caller-provided steps into its own scaffolding, so composition happens by parameterizing inputs or chaining jobs, and “scaffolding you can’t opt out of” is enforced with required workflows/rulesets instead of splice points. Same goals, different grain: GitHub governs at the workflow boundary, Azure Pipelines composes inside the stage.
A product team asks for the deploy-stage template to make the health-report step optional: 'our service has no health endpoint yet, and the failing report step is blocking deploys.' What's the platform-product answer?
Key takeaway
One governance note the splice demands: consumer-injected steps execute inside the deployment job, with its service connection — ancestry alone does not constrain what they do with it. The reference pattern (Microsoft’s own ‘security through templates’ guidance) iterates parameters.deploySteps at compile time and fails the template on disallowed step or task types, so the scaffold owns not just the wrapping but the allowlist of what may be wrapped.
Three tiers: atomic steps, stage templates that own un-opt-outable scaffolding and inject consumer step lists, and copy-me starters that keep team pipelines twenty lines long. Consumers pin the template repo by tag and extend starters — two threads the next lesson pulls into versioning and governance. The product’s promise: divergence only where it’s chosen.