Role Anti Pattern #

Roles are Ansible’s main unit of reusability and modularity. Through roles, we can group tasks, templates, files, handlers, and variables into one standardized folder structure that’s easy to distribute. However, roles written poorly or hastily can backfire. They often have hidden assumptions, undocumented dependencies, tight coupling with other roles, or unexpected behavior that breaks system idempotency. Anti-patterns in roles usually develop organically when a playbook is decomposed without careful architectural planning. This article discusses various anti-patterns often found when writing Ansible roles and how to design clean, modular, reliable, and easy-to-test roles.

Decision Flow for Designing Modular Roles #

Before writing a new role, we must determine its responsibility boundaries and variable structure. The flow diagram below helps us identify the design decisions that must be made so roles stay modular and self-contained.

flowchart TD
    A["Start Designing a New Role"] --> B{"Does the role have a single responsibility?"}
    B -- "No" --> C["Split into several standalone roles"]
    B -- "Yes" --> D{"Are there configuration variables?"}
    D -- "Yes" --> E["Put default variables in defaults/main.yml"]
    D -- "No" --> F{"Need external dependencies?"}
    E --> G{"Are there internal constant variables?"}
    G -- "Yes" --> H["Put them in vars/main.yml"]
    G -- "No" --> F
    H --> F
    F -- "Yes" --> I["Define dependencies in meta/main.yml"]
    F -- "No" --> J["Create the main tasks in tasks/main.yml"]
    I --> J
    J --> K["Done"]

1. Monolithic Roles Doing Too Much (God Roles) #

This anti-pattern happens when a role is designed to handle the entire configuration of one server type at once, instead of splitting by specific components or services.

Why Does This Make Things Difficult? #

When we create a giant role named setup-server or common-prod that installs nginx, sets up a database, copies a Node.js application, configures backup crons, and configures the firewall in one tasks folder:

  1. Not Reusable: If our database server doesn’t need nginx, we can’t use this role because nginx is already baked in.
  2. Hard to Test: Running automated tests (like with Molecule) on a monolithic role requires huge resources because all components must be started at once.
  3. Confusing for Other Developers: A tasks/main.yml with hundreds of lines makes it hard for other developers to track where the firewall configuration is set or where the user configuration is created.

Directory Structure Comparison #

# ANTI-PATTERN: one giant monolithic role
roles/setup-server/
  ├── tasks/
  │   └── main.yml   ← (500+ lines of nginx, db, user, backup, firewall config tasks)
  ├── templates/
  │   ├── nginx.conf.j2
  │   ├── pg_hba.conf.j2
  │   └── backup.sh.j2
  └── defaults/
      └── main.yml   ← (100+ variables for various applications)
# CORRECT: split into several roles with single responsibilities
roles/
  ├── common/        ← (Basic OS setup: users, timezone, ssh)
  ├── nginx/         ← (nginx installation and configuration)
  ├── postgresql/    ← (PostgreSQL installation and configuration)
  ├── myapp/         ← (Application codebase deployment)
  └── firewall/      ← (iptables/ufw rules)

With a modular structure, our playbooks only need to declaratively call the needed roles at the play level. This gives full flexibility to combine different components for each server host type.


2. Required Variables Without Documentation and Validation #

When a role needs external input variables (like database credentials or API keys) but doesn’t document or validate their existence before tasks run.

Failure Impact #

Role users (or CI/CD pipelines) only realize a variable is missing after the playbook runs mid-deployment and produces a Jinja2 template error. This breaks the deployment and can leave the target system in an inconsistent state (partial state).

Code Comparison #

# ANTI-PATTERN: the role directly uses variables without checks or documentation
# roles/myapp/tasks/main.yml
- name: Deploy the application database configuration file
  template:
    src: config.json.j2
    dest: /opt/myapp/config.json
  # This task directly calls {{ db_password }} and {{ api_secret_key }}.
  # If these variables aren't provided in the inventory, the task crashes mid-way.
# CORRECT: use argument_specs for modern validation, or assert as a fail-safe
# Approach 1: Using roles/myapp/meta/argument_specs.yml (Ansible >= 2.11)
argument_specs:
  main:
    short_description: Deploy and configure the backend application
    options:
      db_password:
        type: str
        required: true
        description: Main database password. Use Ansible Vault to secure it.
        no_log: true
      api_secret_key:
        type: str
        required: true
        description: Secret API key for payment gateway integration.
        no_log: true
      app_port:
        type: int
        required: false
        default: 8080
        description: HTTP port used by the backend application.

# Approach 2: Using assert at the start of tasks as a fail-safe (All Ansible versions)
# roles/myapp/tasks/main.yml
- name: Validate the required input variables
  assert:
    that:
      - db_password is defined and db_password | length > 0
      - api_secret_key is defined and api_secret_key | length > 0
    fail_msg: >
      Error: The 'db_password' and 'api_secret_key' variables must be defined!
      Please check the README.md documentation of the myapp role.      

- name: Deploy the application database configuration file
  template:
    src: config.json.j2
    dest: /opt/myapp/config.json

By validating at the start, we safely stop the playbook execution process and provide a clear error message before any target system configuration is changed.


3. Tight Cross-Role Coupling #

This anti-pattern happens when a role implicitly accesses internal variables belonging to another role.

Why Is This Dangerous? #

It creates hidden coupling. If we move the role to another playbook that doesn’t call the database role, our application role fails because it can’t find the database’s internal variables. This destroys the encapsulation principle of a role.

Code Comparison #

# ANTI-PATTERN: the myapp role directly uses the postgresql role's internal variables
# roles/myapp/tasks/main.yml
- name: Wait for the database port to open
  wait_for:
    host: "{{ postgresql_listen_addresses }}"  # Internal variable from the postgresql role!
    port: "{{ postgresql_port }}"              # Hidden coupling!
    timeout: 30
# CORRECT: the role defines its own variable interface
# roles/myapp/defaults/main.yml
# We define default variables with our own myapp role namespace
myapp_db_host: "127.0.0.1"
myapp_db_port: 5432

# roles/myapp/tasks/main.yml
- name: Wait for the database port to open
  wait_for:
    host: "{{ myapp_db_host }}"
    port: "{{ myapp_db_port | int }}"
    timeout: 30

# At the Playbook / Inventory level, we connect them explicitly:
# group_vars/all.yml
myapp_db_host: "{{ postgresql_listen_addresses | default('127.0.0.1') }}"
myapp_db_port: "{{ postgresql_port | default(5432) }}"

By separating variables, the myapp role doesn’t need to know how the postgresql role manages its variables. We synchronize both values at the global configuration level (like group_vars), not inside the role’s internal code.


4. Empty or Missing defaults/main.yml #

Leaving the defaults folder empty and forcing users to supply every small configuration manually is a design mistake making the role hard to use.

Why Are Defaults So Important? #

A well-designed role should be runnable immediately with minimal configuration. The defaults/main.yml file is where we put default values with the lowest precedence (Precedence Level 1). This gives users the flexibility to override those variables only if they need to. If we don’t provide them, users must guess the structure and basic application-friendly configuration values.

Code Comparison #

# ANTI-PATTERN: an empty defaults/main.yml file
# Users must define the install path, user, group, log dir, etc.
# If forgotten, tasks error because variables are undefined.
# CORRECT: provide defaults with sensible, safe values
# roles/nginx/defaults/main.yml
nginx_user: www-data
nginx_group: www-data
nginx_worker_processes: "auto"
nginx_client_max_body_size: "10M"
nginx_keepalive_timeout: 65
nginx_conf_dir: /etc/nginx
nginx_log_dir: /var/log/nginx
nginx_enable_gzip: "on"

By providing defaults like the above, role users just write roles: [ nginx ] in their playbooks to get a standard, safe nginx installation without defining a single extra variable.


5. Storing User Configuration in vars/main.yml #

This mistake happens from misunderstanding variable precedence in Ansible. Many beginner developers put user-changeable configuration in the vars/main.yml file instead of defaults/main.yml.

Precedence Difference #

Variables defined in vars/main.yml have very high precedence (Level 15). This means those variables can’t be overridden through group_vars, host_vars, or the vars parameter at the regular playbook level. The only way to override them is using Extra Vars (-e), which is very impractical for daily configuration.

Code Comparison #

# ANTI-PATTERN: storing user configuration variables in vars/main.yml
# roles/nginx/vars/main.yml
nginx_port: 80
nginx_max_connections: 1024
# If a user wants to change the port to 8080 for a specific server via host_vars,
# the change won't take effect because vars/main.yml overrides host_vars!
# CORRECT: defaults/ for user configuration, vars/ for internal constants
# roles/nginx/defaults/main.yml
nginx_port: 80
nginx_max_connections: 1024
# Users can easily override these values in host_vars/web-01.yml

# roles/nginx/vars/main.yml
# Only use vars/ for system constant variables that users must not change,
# e.g. package lists based on the target operating system.
nginx_os_packages:
  Debian:
    - nginx
    - nginx-common
  RedHat:
    - nginx
    - nginx-filesystem

6. Executing Service Restarts Directly in tasks/main.yml #

Forcing a service to restart mid-way through a role’s task execution, instead of using the handlers mechanism, is an anti-pattern destroying service performance and stability.

Negative Impact #

Just like in playbooks, calling the systemd restart module directly in tasks causes the service to always stop-start every time the role is called, even when there’s no configuration change at all. This causes downtime on production servers without a valid reason.

Code Comparison #

# ANTI-PATTERN: manually restarting the service in tasks
# roles/nginx/tasks/main.yml
- name: Deploy the virtual host configuration
  template:
    src: vhost.conf.j2
    dest: /etc/nginx/sites-enabled/app.conf

- name: Restart the nginx service
  systemd:
    name: nginx
    state: restarted
  # Always restarts nginx every time the role runs!
# CORRECT: use notify to an isolated handler
# roles/nginx/tasks/main.yml
- name: Deploy the virtual host configuration
  template:
    src: vhost.conf.j2
    dest: /etc/nginx/sites-enabled/app.conf
  notify: Reload nginx
  # Only triggers a reload if the configuration changes

# roles/nginx/handlers/main.yml
- name: Reload nginx
  systemd:
    name: nginx
    state: reloaded

7. Writing Tasks That Break System Idempotency #

Writing tasks inside a role that always modify files or permanently run external commands without checking the current system state.

The Problem #

If our role runs in a nightly build or scheduled automation, a non-idempotent role keeps adding text lines, damaging system files, or creating duplicate resources that can cause server dysfunction.

Code Comparison #

# ANTI-PATTERN: generating an SSL key without idempotency protection
# roles/common/tasks/main.yml
- name: Generate the SSL certificate key pair
  command: openssl req -new -newkey rsa:2048 -days 365 -nodes -x509 -keyout /etc/ssl/private/app.key -out /etc/ssl/certs/app.crt -subj "/CN=myapp.internal"
  # Overwrites the old certificate and creates a new one every run!
# CORRECT: use the creates parameter to guarantee idempotency
# roles/common/tasks/main.yml
- name: Generate the SSL certificate key pair if it doesn't exist
  command: openssl req -new -newkey rsa:2048 -days 365 -nodes -x509 -keyout /etc/ssl/private/app.key -out /etc/ssl/certs/app.crt -subj "/CN=myapp.internal"
  args:
    creates: /etc/ssl/private/app.key
  # The task is skipped if the app.key file already exists on the target system

8. Ignoring Automated Testing with Molecule #

Developing roles without writing automated test scenarios (unit testing) is an anti-pattern in the modern development lifecycle (devops lifecycle).

Problems Faced #

Without automated tests, we don’t know whether our role still works well on the latest operating system versions (e.g. migrating from Ubuntu 20.04 to 22.04). We only learn of a failure during a production deployment, which is a very risky situation.

Solution: Use Molecule #

Molecule is a testing framework designed specifically for Ansible roles. Molecule makes it easy to spin up Docker containers, run the role on those containers, verify idempotency status, and run compliance tests (linting).

# Basic Molecule initialization structure on a role
roles/myapp/
  ├── molecule/
  │   └── default/
  │       ├── molecule.yml  ← Driver configuration (Docker/Podman) and verifier
  │       ├── prepare.yml   ← Prepares the test container environment
  │       ├── converge.yml  ← Playbook to run the role being tested
  │       └── verify.yml    ← Script to verify the container's final state

By leveraging Molecule, every code change to a role can be automatically tested through a CI/CD pipeline before the role is merged into the main branch.


9. Non-Standard Folder Structures and Messy Files #

Refusing to use the standard Ansible Galaxy folder structure and creating a confusing custom file layout for other developers.

Impact #

Ansible relies on convention over configuration. If we put handler files inside the tasks folder or store templates in the files folder, Ansible can’t load them automatically without us writing complicated full paths. This complicates team collaboration.

Solution: Follow the Ansible Galaxy Standard #

Always create new roles with the ansible-galaxy role init command to generate the standard folder skeleton:

roles/role-name/
  ├── README.md        ← Usage documentation, input variables, and example playbooks
  ├── meta/
  │   └── main.yml     ← Author info, license, and role dependencies
  ├── defaults/
  │   └── main.yml     ← Low-precedence default variables
  ├── vars/
  │   └── main.yml     ← High-precedence internal constant variables
  ├── tasks/
  │   └── main.yml     ← The main task list to execute
  ├── handlers/
  │   └── main.yml     ← Handlers responding to task status changes
  ├── templates/       ← Jinja2 template files (.j2)
  └── files/           ← Static files copied as-is (scripts, tar.gz)

Summary #

  • Divide Responsibilities (SRP) — Split giant monolithic roles into several small roles focused on a single component or application.
  • Validate Required Variables — Apply documented argument specs using meta/argument_specs.yml or assert at the start of tasks to avoid silent crashes.
  • Decouple Variable Dependencies — Use your own role namespace in defaults/main.yml and avoid directly calling other roles’ internal variables.
  • Apply Sensible Defaults — Ensure defaults/main.yml is filled with safe built-in values so the role works immediately without convoluted manual setup.
  • Understand vars/ Precedence — Store only system constant variables that must not change in vars/main.yml, and use defaults/ for configuration variables.
  • Move Restarts to Handlers — Delegate service restart/reload tasks to handlers/main.yml so brief downtime on target servers doesn’t happen repeatedly.
  • Use Molecule for Testing — Integrate the Molecule framework for automatic unit testing of roles on Docker platforms before releasing to production.

← Previous: Playbook Anti Pattern Next: Variable Anti Pattern →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact