How should you design Ansible roles for reusability, and what anti-patterns should you avoid?
Quick Answer
Reusable roles use meta/main.yml for dependencies, import_role for static inclusion at parse time, include_role for dynamic inclusion at runtime, Molecule for isolated testing, and a clear variable strategy using defaults and vars. Common anti-patterns include circular dependencies, overusing include_role with complex conditionals, and relying on global variables instead of explicit role parameters.
Detailed Answer
Think of building with LEGO sets. Each set (role) has its own instruction manual and parts list. Some sets need other sets as prerequisites -- you need the chassis before you can attach the engine (meta dependencies). You can either pre-assemble sub-modules before starting the main build (import_role, which is static) or build them on the fly as you reach each step (include_role, which is dynamic). Molecule is the quality inspection station where each set gets tested on its own before shipping. Variable precedence is like labeling each brick with where it came from so you know which one wins when two sets include the same part number.
In Ansible, role meta dependencies declared in meta/main.yml cause dependent roles to run automatically before the declaring role. If the payments-api role depends on the nginx role and the tls-certs role, Ansible runs both dependencies first. However, meta dependencies are deduplicated by default -- if two roles declare the same dependency, it runs only once unless you set allow_duplicates: true. This surprises architects when a shared role needs to run with different settings for different consumers.
The key design decision is import_role versus include_role. import_role is processed when Ansible first reads the playbook -- all tasks, handlers, and variables from the imported role are merged into the play before anything runs. This enables static analysis, --list-tasks, and tag filtering, but means conditional logic (when clauses on import_role) gets applied to every task inside the role rather than gating the whole role. include_role is evaluated during execution, meaning the role's tasks are loaded only when the include_role task actually runs. This supports looping over roles and true conditional gating but hides the role's contents from static analysis tools.
Molecule provides isolated role testing by creating temporary infrastructure (Docker containers, Vagrant VMs, or cloud instances), running the role against them, checking the results with verifiers (testinfra, ansible assertions, or goss), and tearing everything down. Production-grade roles include a molecule/ directory with test scenarios for each supported platform. CI pipelines should run molecule test on every role change before publishing to Galaxy or Automation Hub. For variables, Ansible has a 22-level priority hierarchy -- role defaults (lowest) are meant to be overridden by consumers, role vars are stronger than inventory variables, and extra vars (--extra-vars) override everything. Well-designed roles put all user-configurable values in defaults/main.yml and reserve vars/main.yml for internal constants.
The sneaky gotcha is variable scope leakage between roles. When a role sets a variable using set_fact, that variable sticks around in the host's scope for the rest of the play, potentially overriding a same-named variable in a later role. Architects should prefix all role variables with the role name (payments_api_port instead of port) and put configurable values in defaults/ rather than using set_fact. Another anti-pattern is deep meta dependency chains -- when role A depends on B, B depends on C, and C depends on D, debugging becomes nearly impossible and execution order surprises multiply. Flatten dependency trees by having playbooks explicitly order roles rather than relying on transitive meta dependencies.