In Azure Pipelines, how does a pipeline that begins with extends: differ from one that merely references a template under steps:, and why do platform teams standardise on extends?
answer
- who holds the pen on the document
- the consumer contributes only inputs
- the base decides where injected steps land
- compile-time type checking, before any agent
- a check on the credential turns convention into rule
basics
~20 sAn included template is content the pipeline chooses to pull in; an extends template owns the whole pipeline, and the extending file can contribute nothing but parameter values. That inversion is what lets a platform team fix the pipeline's shape and let product teams fill in only the holes it leaves.
solid answer
~50 sA `- template:` reference under `steps:`, `jobs:` or `stages:` is **inclusion**: the consuming pipeline is in charge and pulls a fragment in wherever it likes — and can equally choose not to. `extends:` inverts that. The file says `extends: template: base.yml@templates` plus a `parameters:` block, and the base template defines every stage, job and step; the consumer contributes only typed parameter values. Because the base controls the structure, it can guarantee that its own steps — signing, scanning, provenance — run, and it decides where any caller-supplied steps land, by declaring a `stepList` parameter and placing `${{ parameters.buildSteps }}` inside a job it wrote. It can even iterate those injected steps with `${{ each }}` and refuse to compile ones that violate policy. On its own that is still a convention; what makes it enforcement is the **Required template** check on a protected resource, which fails any pipeline that does not extend the named template before it may use that service connection, environment or pool.
code
yaml · 28 linesparameters:
- name: serviceName
type: string
- name: runtime
type: string
default: node20
values:
- node20
- dotnet8
- name: buildSteps
type: stepList
default: []
stages:
- stage: Build
jobs:
- job: Build
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
- script: echo "building ${{ parameters.serviceName }} on ${{ parameters.runtime }}"
displayName: Banner
- ${{ parameters.buildSteps }}
- script: ./platform/scan.sh
displayName: Mandatory scan
- script: ./platform/sign.sh
displayName: Mandatory signinggo deeper
Know that a template is reusable pipeline YAML and that parameters feed it. Recognise extends: at the top of a file as meaning the pipeline's structure is defined elsewhere.
Explain the direction of control: inclusion is the consumer pulling a fragment in, extension is the template owning the document. Describe typed parameters, values: validation and compile-time failure before any agent is allocated.
Show the injection pattern with a stepList parameter and explain why the Required template check on a protected resource is what turns a convention into an enforced control. Diagnose a template change that broke every consumer at once.
Own the platform-versus-autonomy tradeoff: how templates are versioned and migrated, how much variation the parameter surface may absorb before it becomes unmaintainable, and how you make the guarded credential — not goodwill — the enforcement boundary.
## Two ways to reference a template Azure Pipelines has one template file format and two ways to point at one. **Inclusion** — the consuming pipeline is the author of the document and splices a fragment in: ```yaml steps: - template: steps/build.yml@templates parameters: buildConfiguration: Release - script: ./deploy.sh # the consumer can add whatever it likes ``` **Extension** — the template is the author of the document, and the consumer supplies only inputs: ```yaml resources: repositories: - repository: templates type: git name: platform/pipeline-templates ref: refs/tags/v3.2.0 extends: template: base-service.yml@templates parameters: serviceName: checkout-api buildSteps: - script: npm ci - script: npm run build ``` The difference is *who holds the pen*. With inclusion, the consumer can skip the template, reorder around it, or add a step that undoes what it did. With `extends:`, the top level of the file is the `extends` key — there are no sibling `stages:` or `steps:` to add — so the base template's structure is the run's structure. ## Typed parameters are the interface A template declares its inputs with types, defaults and optionally an allowed set: ```yaml parameters: - name: serviceName type: string - name: runtime type: string default: node20 values: - node20 - dotnet8 - name: buildSteps type: stepList default: [] ``` The scalar types (`string`, `number`, `boolean`, `object`) give you a validated contract: a value outside `values:` fails at **compile time**, before an agent is ever allocated, which is a far better place to catch a typo than twenty minutes into a run. The structural types are what make governance possible: `stepList`, `jobList` and `stageList` (and their singular forms) let a caller hand over blocks of pipeline as data. ## Injection: the base decides *where* consumer content goes The base template places the caller's steps at a point of its own choosing, surrounded by steps the caller cannot remove: ```yaml parameters: - name: buildSteps type: stepList default: [] jobs: - job: Build steps: - checkout: self - template: internal/provenance-start.yml - ${{ parameters.buildSteps }} # caller's content lands here - template: internal/scan.yml - template: internal/sign-and-publish.yml ``` The caller gets a slot, not the document. And because `${{ each }}` runs at compile time, the base can inspect what it was handed and reject it: ```yaml - ${{ each step in parameters.buildSteps }}: - ${{ each pair in step }}: - ${{ if not(in(pair.key, 'script', 'bash', 'pwsh', 'displayName', 'env')) }}: - 'ERROR: only script steps may be injected' ``` Emitting an invalid entry deliberately breaks expansion, so a non-conforming pipeline never starts. It is a blunt instrument, but it runs before any agent is allocated and before any secret is fetched. ## Convention versus enforcement None of the above stops a team from simply *not* extending the template — they can write a plain pipeline and skip the platform entirely. The missing piece is the **Required template** check, configured on a protected resource: a service connection, an environment, or an agent pool. The check declares a repository, a `ref` and a template path, and any pipeline that wants to use that resource must extend exactly that template or it fails immediately. That combination is the real design: - the production **service connection** is a protected resource; - it carries a Required-template check naming `platform/pipeline-templates` at `base-service.yml`; - therefore nothing can deploy to production except through the platform's template; - therefore the signing and scanning steps embedded in that template are unavoidable. The governance does not rest on people writing YAML nicely. It rests on the fact that the credential they need is guarded. ## Versioning and blast radius Because the template repository is declared as a `resources: repositories:` entry, its `ref` can be pinned to a tag. Consumers on `refs/tags/v3` get a stable shape; the platform team ships `v4` and migrates callers deliberately. Pointing every consumer at `refs/heads/main` is the alternative and it means every edit to the base template is an immediate, organisation-wide change — occasionally what you want for a security fix, usually not what you want on a Tuesday afternoon. The honest cost of `extends` is rigidity. Every legitimate variation has to be modelled as a parameter, and a template that grows thirty booleans has become a programming language with none of the tooling. The signal to split is when parameters start describing *how* rather than *what*.
- What stops a team from simply not extending the platform template?Nothing in YAML — extension is opt-in by itself. Enforcement comes from a Required template check on a protected resource such as the production service connection, environment or agent pool. Any pipeline wanting that resource must extend the named template at the named ref, so the credential is what makes the template unavoidable.
- How does a base template let callers contribute steps without giving up control of the pipeline?By declaring a `stepList` parameter and placing `${{ parameters.buildSteps }}` at a chosen point inside a job it wrote. The caller supplies a block of steps as data; the template surrounds it with checkout, scanning and signing steps the caller cannot remove or reorder, because the caller never authors the job.
- Why pin the template repository to a tag rather than a branch?A `ref` of `refs/heads/main` makes every commit to the base template an instant organisation-wide change to every consumer's pipeline shape. Pinning to `refs/tags/v3` gives consumers a stable contract and the platform team a deliberate migration path — while still allowing a coordinated re-point when a security fix must land everywhere at once.
- When has an extends template gone too far?When its parameters start describing *how* the work happens rather than *what* is being built — dozens of booleans toggling internal behaviour. At that point it is a programming language without a debugger, and the readable move is to split it into a small family of templates per workload shape rather than one universal template with a switchboard.
saying these in an interview costs you the question
- Treats extends: and - template: as the same feature
- Thinks an extending pipeline can add its own top-level stages
- Says extends alone prevents teams bypassing the template
- Believes template parameters are plain untyped strings
- Points every consumer at the template repo's main branch