The problem
Ten services with the same "build, test, scan, push" stages means ten copies of the same YAML drifting apart: someone fixes a bug in one copy and nine keep it. And every run downloads the same hundreds of Maven dependencies again. This lesson is about writing the pipeline once (templates), running one job in several variants (a matrix), and not downloading what you already have (a cache).
What you need to know already: 25.4 (stages, jobs, steps, the agent's workspace), 25.7 (the three syntaxes: ${{ }} at compile time, $( ) and $[ ] later).
Templates
A template (pipeline sense) is a YAML file with parameters: and one of steps:, jobs:, stages: or variables:: a reusable piece of pipeline. A parameter is an input the caller fills in, like a function argument. The template is pasted in at compile time, before the run starts:
# templates/maven-build.yml
parameters:
- name: goals
type: string
default: verify
- name: jdk
type: string
default: '21'
values: ['17', '21'] # compile-time validation
steps:
- script: |
set -euo pipefail
./mvnw -B ${{ parameters.goals }}
displayName: Maven ${{ parameters.goals }} (JDK ${{ parameters.jdk }})
# azure-pipelines.yml
steps:
- template: templates/maven-build.yml
parameters:
goals: package
Reading the two files: the template declares two parameters, each with a type and a default (used when the caller gives nothing); values: lists the only allowed values. Inside, ${{ parameters.goals }} is replaced by the caller's value. The pipeline uses it with - template: FILE plus the parameters: it wants to set - here goals: package, so the step becomes ./mvnw -B package. jdk keeps its default '21'.
Parameter types: string, number, boolean, object, step, stepList, job, jobList, stage, stageList. A missing parameter without a default, a wrong type or a value outside values: fails the run before it starts:
/templates/maven-build.yml: The 'jdk' parameter value '11' is not a valid value.
Compile-time logic
steps:
- ${{ if eq(parameters.publish, true) }}:
- publish: target
artifact: jar
- ${{ each env in parameters.environments }}:
- script: echo "deploy to ${{ env }}"
${{ if COND }}: keeps the indented lines under it only when COND is true (here: publish the jar only if the publish parameter is true). ${{ each X in LIST }}: repeats the lines under it once per item (one deploy step per environment in the list).
${{ if }} and ${{ each }} are evaluated while compiling - they shape the pipeline. They only see parameters and variables written in the YAML, not runtime values like $(Build.BuildId) or a variable set by a script. For runtime decisions use condition: on the step or job.
Sharing templates across repos
A platform team keeps shared templates in their own repo. resources: repositories: declares that other repo under a short alias (templates); ref: says which branch or tag to read. extends: then says "this whole pipeline IS that template, with these parameters"; FILE@templates means FILE from the repo with alias templates.
resources:
repositories:
- repository: templates
type: git
name: platform/pipeline-templates
ref: refs/tags/v3 # pin a version of the shared templates
extends:
template: java-service.yml@templates # the whole pipeline comes from the platform team
parameters:
serviceName: payments-api
extends is how platform teams enforce standards: the service's pipeline can only fill in parameters; the scan and approval stages are not optional. Pin the template repo to a tag - a change to main of the templates repo otherwise changes every pipeline at once.
Matrix builds
A matrix runs the same job several times with different variables - here once per Java version (JDK = Java Development Kit, Ch 20). maxParallel caps how many copies run at the same time.
- job: build
strategy:
matrix:
jdk17:
JDK: '17'
jdk21:
JDK: '21'
maxParallel: 2
steps:
- script: echo "building on JDK $(JDK)"
One job definition becomes one job per matrix entry, named build_jdk17 and build_jdk21, each with its variables. They run in parallel if there are enough agents - with one self-hosted agent they queue one after the other. Matrix values are runtime variables: $(JDK) works, ${{ variables.JDK }} does not (it is empty at compile time), and passing $(JDK) into a template parameter that has values: fails the compile-time check because the parameter literally receives the text $(JDK).
Caching
A cache (pipeline sense) is a directory the pipeline saves at the end of a job and restores at the start of a later one, so the dependencies Maven downloaded (into .m2/repository, its local store) do not have to be downloaded again. An artifact is what you ship; a cache is only a speed-up, and the job must still work without it.
Cache@2 inputs: key names the cache, restoreKeys are fall-backs, path is the directory to save and restore. -Dmaven.repo.local=DIR tells Maven to use DIR as its local store, so it uses the cached one.
- task: Cache@2
inputs:
key: 'maven | "$(Agent.OS)" | pom.xml'
restoreKeys: |
maven | "$(Agent.OS)"
path: $(Pipeline.Workspace)/.m2/repository
- script: ./mvnw -B verify -Dmaven.repo.local=$(Pipeline.Workspace)/.m2/repository
The key is a list of segments joined by |: literal strings, and file paths or globs whose content hash (a fingerprint of the file: change one byte and it changes) becomes part of the key. Change pom.xml and the key changes: a cache miss, a full download, and a new cache saved at the end of the job (a post-job step: one the task adds after your last step). restoreKeys are prefixes tried when the exact key misses: you get the closest older cache and Maven only downloads what changed.
Resolving key:
- maven [string]
- "Linux" [string]
- pom.xml [file pattern; matches: 1]
- s/pom.xml --> CFA97E63D1ED9B5FD5FF1B6613BED54500AB41DE
Resolved to: maven|"Linux"|NkE3RUIy...=
...
There is a cache miss.
...
##[section]Starting: Post-job: Cache the Maven repository
...
Cache saved successfully
The log shows the key being worked out from its three parts, then the miss, then the save at the end.
Rules: cache dependencies, never build outputs you then ship (that is what artifacts are for); a cache is saved only when the job succeeds; caches are scoped per branch with fallback to the default branch.
Self-hosted agents and "clean"
On a self-hosted agent $(Pipeline.Workspace) survives between runs, so a local Maven repo there is effectively a free cache - and also a source of "works on the agent, fails on a fresh one". workspace: clean: all on a job wipes it first (workspace: is a job setting, at the same level as steps:), which makes self-hosted behave like a fresh VM. Do that whenever you want to prove a pipeline is reproducible.
See what will actually run
Templates, parameters, ${{ if }} and each make the file you read differ from the pipeline that runs. The web UI has "Download full YAML"; here ci validate PIPELINE (simulator) compiles the pipeline from the repo on git.lab and prints the result, with every template pasted in:
$ ci validate payments-api
stages:
- jobs:
- job: build
steps:
- displayName: Cache the Maven repository
inputs: ...
Always look at the expanded form when a template "does nothing".
What you can now do
- Move repeated steps into a template with typed parameters and call it.
- Run one job for several JDKs with a matrix, and say why
$(JDK)cannot feed avalues:check. - Cache Maven dependencies with
Cache@2and read a cache miss, save and restore in the log.