Skip to main content
RunBook Academy

AnsibleXXII · Roles and ReuseRoles and reuse

Handlers across role boundaries

Advanced⏱ ~19 minansible-playbook

What you'll learn

  • State why role handlers are not scoped to their role
  • Predict which handler fires when two roles define the same name
  • Use listen topics and name prefixes as cross-role interfaces
  • Audit an existing repository for handler collisions

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

Not yet marked complete on this device.

Everything else about a role is scoped. Its defaults lose to the inventory, its vars are entered and left, its templates/ directory only affects lookups inside it.

Handlers are not. roles/webapp/handlers/main.yml is loaded into the parent play’s handler list, alongside the play’s own handlers and every other role’s. One flat namespace, keyed by name, with no role prefix and no warning on collision.

Two roles that each define a handler called restart nginx are two definitions of one name. Notifying it runs one of them. Not necessarily yours.

The collision, executed

Two roles. ra changes a config file and notifies restart nginx; its own handlers file defines that name. rb also defines a handler called restart nginx — a different one, restarting a different unit — and does nothing else in this play.

# roles/ra/tasks/main.yml
- name: role A changes config
  ansible.builtin.debug: {msg: A-config}
  changed_when: true
  notify: restart nginx
# roles/ra/handlers/main.yml
- name: restart nginx
  ansible.builtin.debug:
    msg: "ROLE-A handler ran"
# roles/rb/handlers/main.yml
- name: restart nginx
  ansible.builtin.debug:
    msg: "ROLE-B handler ran"
- hosts: web
  roles:
    - ra
    - rb
Read-only / Saferole A notified; role B answered
$ ansible-playbook -i inventory.ini collide.yml
TASK [ra : role A changes config] **********************************************
  "msg": "A-config"

TASK [rb : role B does nothing interesting] ************************************
  "msg": "B-noop"

RUNNING HANDLER [rb : restart nginx] *******************************************
  "msg": "ROLE-B handler ran"

PLAY RECAP *********************************************************************
localhost                  : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Read the handler line: RUNNING HANDLER [rb : restart nginx]. Role A’s task notified, role B’s handler ran, and role A’s handler never executed at all. The run reported success.

In production, with real service modules behind those names, that is a config change to service A followed by a restart of service B. Service A keeps serving its old configuration — so the change appears to have been applied and did nothing — and service B takes an unexplained restart during a maintenance window nobody scheduled for it.

Which one wins

The later definition replaces the earlier one for that name. Roles are loaded in the order the play lists them, so the last role to define a name is the one that gets notified — regardless of which role did the notifying.

That ordering dependence is the part that makes this hard to catch. Reorder the roles: list for an unrelated reason and the behaviour changes. The review that approved the reorder saw two role names swap places, not a different service restarting.

Fix one: listen topics

listen: gives a handler an additional name that is explicitly a topic — something other content notifies without knowing which handler answers.

# roles/ra/handlers/main.yml
- name: restart nginx
  ansible.builtin.debug:
    msg: "ROLE-A handler ran"
  listen: reload web tier
# roles/ra/tasks/main.yml
- name: role A changes config
  ansible.builtin.debug: {msg: A-config}
  changed_when: true
  notify: reload web tier
Read-only / Safenotify by topic, with the colliding names left in place
$ ansible-playbook -i inventory.ini collide.yml
RUNNING HANDLER [ra : restart nginx] *******************************************
  "msg": "ROLE-A handler ran"

PLAY RECAP *********************************************************************
localhost                  : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Topics are many-to-many by design: several handlers may listen to one topic and all of them run. That makes listen the right interface when a change should trigger work in more than one role — “the certificate changed” ought to reload nginx and haproxy, and neither role should have to know the other exists.

It is the weaker fix for the collision itself, though, because the colliding names are still there for the next person to notify by accident.

Fix two: namespace the names

The fix that removes the problem rather than routing around it is the naming discipline from Part XIII lesson 7, applied to handlers:

# roles/ra/handlers/main.yml
- name: ra restart nginx
  ansible.builtin.debug:
    msg: "ROLE-A handler ran"
Read-only / Safeprefixed names cannot collide
$ ansible-playbook -i inventory.ini collide.yml
RUNNING HANDLER [ra : ra restart nginx] ****************************************
  "msg": "ROLE-A handler ran"

PLAY RECAP *********************************************************************
localhost                  : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

The line reads redundantly — ra : ra restart nginx — and that redundancy is the price of a name that cannot be claimed by somebody else’s role. Use the role name as the prefix and the redundancy is at least predictable.

Use both. Prefix every handler name so nothing can collide, and add a listen: topic for the handlers that other roles legitimately need to trigger. The prefix protects the implementation; the topic is the interface.

Auditing an estate you inherited

Collisions are cheap to find and worth finding before an incident does it for you:

Read-only / Safehandler names defined more than once across roles
grep -rhoP '^-\s+name:\s+\K.*' roles/*/handlers/*.yml \
| sed 's/[[:space:]]*$//' \
| sort | uniq -d

Anything printed is a name two or more roles define. For each one, decide which role owns it, prefix the others, and re-run.

Then check the notify side, because a handler defined once can still be notified from a role that did not mean this one:

Read-only / Safewhich role notifies which name
grep -rn 'notify:' roles/*/tasks/ | sed 's|roles/||'

Knowledge check

Knowledge check · 4 questions

  1. Q1. Two roles in one play each define a handler named restart nginx. A task in the first role notifies that name. What happens?

  2. Q2. Which changes remove or route around a cross-role handler name collision? Select all that apply.

  3. Q3. A notify referring to a handler name that no handler defines fails the run, so typos in notify are loud.

  4. Q4. An operator reports that a config change is not taking effect, and checks that the task reports changed and that a RUNNING HANDLER block appears in the output. Both are true. What should they look at next?

Passing score: 75%. Answers are checked in this browser.