AnsibleXXII · Roles and ReuseRoles and reuse
Refactoring a monolith into roles
What you'll learn
- Sequence a monolith-to-roles migration so every step is separately reviewable
- Prove a refactor changed structure and not behaviour, with evidence
- Preserve the --tags and --limit contracts operators already rely on
- Choose what to extract first from the change history rather than from taste
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
Almost nobody starts with roles. Estates start with a playbook, the playbook
grows, and one day it is 900 lines with four plays and a tasks: list that
nobody reads end to end. The refactor to roles is a well-understood exercise
that goes wrong for a reason that has nothing to do with roles:
A working playbook has an interface, and the interface is not the file.
Operators depend on --tags deploy selecting a specific set of work. A cron
job runs it with --limit web. A runbook says “run it with -e skip_migrations=true if the database team is mid-window”. None of that is
written down, and all of it can break during a refactor that leaves the
resulting host state identical.
So the migration has two jobs: move the tasks, and preserve the contracts.
Establish the baseline before you touch anything
Three artefacts, captured from the current playbook on the current branch. None of them contacts a host except the last, which contacts them read-only.
mkdir -p /tmp/refactor-baseline
ansible-playbook -i inventory/production.ini site.yml --list-tasks > /tmp/refactor-baseline/tasks.txt
ansible-playbook -i inventory/production.ini site.yml --list-tags > /tmp/refactor-baseline/tags.txt
ansible-playbook -i inventory/production.ini site.yml --list-hosts > /tmp/refactor-baseline/hosts.txtThe task list is the one that does the work. Every refactor step is then judged by one question: does the task list still match?
$ ansible-playbook -i inventory/production.ini site.yml --list-tasks | diff /tmp/refactor-baseline/tasks.txt -3c3
< install the application package TAGS: [deploy]
---
> webapp : install the application package TAGS: [deploy]That is the expected diff for an extraction: task names gain a role prefix and nothing else moves. Any other line — a task that disappeared, a tag that changed, an order that shifted — is a behaviour change you did not intend, visible before you ran anything.
What to extract first
Not the biggest section, and not the one you dislike most. Extract in this order:
1. The part with the fewest inbound references. Something at the end of the play that nothing else depends on. Monitoring agent installation is the classic first extraction: it reads a couple of variables, writes a config, starts a service, and nothing later in the playbook cares.
2. The part that changes most often. Ask the repository rather than guessing:
git log --format='%h %ad %s' --date=short -20 -- site.ymlHigh-churn work benefits most from becoming a role, because a role can be tested, versioned and reviewed independently.
3. Anything you already copy-pasted into a second playbook. Duplication is the strongest evidence that a boundary exists; the second copy has already told you where it is.
Leave for last: the part that everything else depends on, usually the baseline or common setup. It is the hardest to extract cleanly and the easiest to break, and by the time you get to it you will have a much better picture of what it actually provides.
The extraction, step by step
For one section, in one pull request:
Move the tasks verbatim. Cut them from site.yml into
roles/monitoring/tasks/main.yml. Do not reword names, do not reorder, do
not “tidy while I am in here”. The diff must stay reviewable, and every edit
you make in this step is an edit whose effect you cannot separate from the
move.
Add the role to the play in the same position. If the tasks ran between two others, the role goes between the same two:
- name: build the web tier
hosts: web
tasks:
- name: install the application package
ansible.builtin.package:
name: webapp
state: present
- name: monitoring
ansible.builtin.import_role:
name: monitoring
- name: start the application
ansible.builtin.systemd_service:
name: webapp
state: started
Use import_role, not include_role. Lesson 5 explains why: the static form
keeps --list-tasks complete, which is the only thing making this refactor
verifiable at all.
Move the variables into defaults/main.yml and namespace them. This is
the step that changes behaviour if you are careless. A variable that was set
in the play’s vars: was at precedence entry 12; in defaults/main.yml it
is at entry 2. Anything in the inventory that previously lost to it now
wins.
Move the handlers, and prefix them. Handlers move to
roles/monitoring/handlers/main.yml, where lesson 7 applies: the name is now
in a shared namespace with every other role’s. Prefix it during the move,
and update the notify: in the same commit.
Diff the task list. Then diff it against the other inventories.
Preserving the operator contracts
Three things break silently. Check each one explicitly.
--tags
Tags that were on individual tasks come along with the tasks. Tags that were
on a block wrapping the section need to be reapplied — put them on the
import_role statement, where lesson 5 established they propagate to every
task inside.
ansible-playbook -i inventory/production.ini site.yml --list-tasks --tags monitoring \
> /tmp/refactor-baseline/tasks-monitoring-after.txt
diff /tmp/refactor-baseline/tasks-monitoring.txt /tmp/refactor-baseline/tasks-monitoring-after.txtDo this for every tag in the baseline tags.txt. It is tedious once and
never again.
--limit
--limit operates on the inventory and is unaffected by roles — unless the
refactor changed a hosts: line, which it should not have. Confirm with
--list-hosts against the same baseline.
-e overrides in runbooks and cron
These are the ones with no artefact to diff, because they live in a wiki page and a crontab. Grep what you can:
grep -rn 'ansible-playbook' /etc/cron.d/ .gitlab-ci.yml .github/workflows/ 2>/dev/nullAny variable passed with -e must still be a name something reads. If the
refactor renamed it — and namespacing renames it — the override becomes inert
and the cron job silently reverts to the default. That is the same failure as
Part XIII lesson 5’s inert group_vars line, arriving through a different
door.
Verifying against real hosts
The task-list diff proves the plan is unchanged. To prove the result is unchanged, run both versions in check mode with diff enabled against a representative host:
ansible-playbook -i inventory/staging.ini site.yml --check --diff --limit web-01.example.comOn a host already converged, both versions should report zero changes. On a host that is not, both should propose the same changes. A difference between them is the refactor altering behaviour.
This is evidence rather than proof, because check mode skips modules that do
not support it and cannot predict the effects of command or shell tasks.
Part XXV covers where check mode tells the truth and where it does not.
Knowledge check
Knowledge check · 4 questions
Q1. What is the single most useful piece of evidence that a monolith-to-roles extraction changed structure and not behaviour?
Q2. Moving a value from a play vars: block into a role defaults/main.yml is behaviour-neutral, since both are just places variables live.
Q3. Which operator-facing contracts can a roles refactor break while leaving the resulting host state identical? Select all that apply.
Q4. Which section of a long playbook is the right one to extract first?
Passing score: 75%. Answers are checked in this browser.