When and Why to Use Parameterized Classes in Puppet: Best Practices for Module Design


2 views

While the example you provided using node-level and class variables works, it creates fragile dependencies between classes. The do_stuff class implicitly depends on variables being set in specific parent scopes. This becomes problematic when:

  • Modules need to be reused across different environments
  • Multiple teams work on the same codebase
  • You need to override specific parameters for testing

Parameterized classes provide explicit interfaces for your modules. Here's how we'd rewrite your example:

class bar (
  String $file_owner = 'larry',
  String $file_name = 'larry.txt'
) {
  class { 'do_stuff':
    file_owner => $file_owner,
    file_name  => $file_name,
  }
}

class do_stuff (
  String $file_name,
  String $file_owner
) {
  file { $file_name:
    ensure => file,
    owner  => $file_owner,
  }
}

node 'foo.com' {
  class { 'bar':
    file_owner => 'custom_owner',
    file_name  => 'special_file.txt',
  }
}

From my experience managing Puppet infrastructure at scale, parameterized classes offer:

Explicit Dependencies

No more guessing where variables come from. The interface is clearly defined in the class declaration.

Default Value Flexibility

You can set sane defaults while still allowing overrides:

class nginx (
  Integer $worker_processes = $facts['processors']['count'],
  Boolean $gzip = true,
  Array[String] $index_files = ['index.html', 'index.htm']
) {
  # class implementation
}

Data Separation

Works beautifully with Hiera:

# hiera.yaml
nginx::worker_processes: 4
nginx::gzip: false

# manifest
include nginx

Type Validation

Puppet's data types provide built-in validation:

class mysql (
  Enum['5.5','5.6','5.7'] $version,
  Variant[String,Array[String]] $bind_address,
  Optional[String] $root_password = undef
) {
  # ...
}

Class Composition

Parameterized classes enable clean component assembly:

class webserver (
  Boolean $ssl         = true,
  String  $cert_source = 'puppet:///modules/certs'
) {
  class { 'nginx': }
  
  if $ssl {
    class { 'letsencrypt':
      email => 'admin@example.com',
    }
  }
  
  file { '/etc/nginx/ssl':
    source => $cert_source,
  }
}

Simple utility classes that don't need configuration might not benefit from parameters. Also avoid when:

  • The class has no configurable behavior
  • Parameters would only be used once in the codebase
  • You're working with very old Puppet versions (pre-3.0)

In your example, you're relying on dynamic scope resolution where variables declared in node definitions or parent classes become available to included classes. While this works, it creates several issues:

# Problematic example with dynamic scoping
node 'foo.com' {
  $file_owner = "larry" 
  include bar 
}

class bar { 
  $file_name = "larry.txt"
  include do_stuff
}

class do_stuff {
  file { $file_name:
    ensure => file,
    owner  => $file_owner,  # This depends on node-level variable
  }
}

Parameterized classes provide explicit interfaces for your modules, making dependencies clear:

# Better approach with parameterized classes
class do_stuff (
  String $file_name,
  String $file_owner
) {
  file { $file_name:
    ensure => file,
    owner  => $file_owner,
  }
}

class bar (
  String $file_owner,
  String $file_name = "larry.txt"
) {
  class { 'do_stuff':
    file_name => $file_name,
    file_owner => $file_owner,
  }
}

node 'foo.com' {
  class { 'bar':
    file_owner => "larry",
  }
}

1. Explicit Dependencies: Parameters clearly declare what a class needs to function
2. Default Values: You can provide fallback values while allowing overrides
3. Data Type Validation: Puppet will validate parameters match expected types
4. Module Reusability: Parameterized classes work consistently across environments

Here's how we structure complex modules using parameterized classes and Hiera:

# Module interface class
class myapp (
  String           $version,
  Optional[String] $custom_config = undef,
  Array[String]    $features      = [],
  Boolean          $enable_monitoring = true
) {
  # Input validation
  if $version !~ /^[0-9]+\.[0-9]+$/ {
    fail("Invalid version format: ${version}")
  }

  # Resource defaults
  File {
    owner => 'myapp',
    group => 'myapp',
    mode  => '0644',
  }

  # Conditional logic based on parameters
  if 'advanced' in $features {
    include myapp::advanced
  }
  
  # Main resources
  package { 'myapp':
    ensure => $version,
  }
  
  file { '/etc/myapp.conf':
    content => template('myapp/config.erb'),
  }
}

# In Hiera YAML:
myapp::version: '2.4'
myapp::features:
  - 'advanced'
  - 'logging'

Parameterized classes aren't always the answer. Simple utility classes or cases where all configuration comes via facts/external data might work better with traditional includes:

# Appropriate for simple cases
class myapp::utils {
  file { '/usr/local/bin/myapp-helper':
    source => 'puppet:///modules/myapp/helper.sh',
    mode   => '0755',
  }
}

# Usage:
include myapp::utils