ServerlessBase Blog
  • Declarative vs Imperative IaC

    Understanding the fundamental differences between declarative and imperative infrastructure as code approaches and when to use each.

    Declarative vs Imperative IaC

    You've probably heard the terms "declarative" and "imperative" thrown around when discussing infrastructure as code. They sound abstract, but they describe two fundamentally different ways of thinking about building and managing systems. Understanding the difference isn't just academic — it affects how you write code, how you debug problems, and how your team collaborates.

    The Core Difference

    Think of it this way: declarative code describes what you want. Imperative code describes how to get there.

    Imperative Approach

    Imperative IaC tells the system exactly what steps to execute in a specific order. It's like giving someone a recipe: "First do this, then do that, finally do this."

    # Imperative example: Creating a user
    useradd -m -s /bin/bash myuser
    echo "myuser:password123" | chpasswd
    usermod -aG sudo myuser

    This script performs actions sequentially. If you run it twice, it will fail on the second run because the user already exists. You have to handle each step explicitly.

    Declarative Approach

    Declarative IaC describes the desired state and lets the system figure out how to achieve it. It's like telling someone "I want a user named myuser with sudo access" and letting them handle the implementation details.

    # Declarative example: Creating a user (Terraform)
    resource "user" "myuser" {
      name  = "myuser"
      shell = "/bin/bash"
      groups = ["sudo"]
    }

    Terraform will create the user if it doesn't exist, update it if it does, and do nothing if everything is already correct. You don't need to worry about the intermediate steps.

    Why This Matters

    The difference becomes clear when things go wrong. With imperative code, you have to trace through each step to understand what happened. With declarative code, you just look at the desired state and compare it to the actual state.

    Debugging Imperative Scripts

    # What if this script fails halfway through?
    useradd -m -s /bin/bash myuser
    echo "myuser:password123" | chpasswd
    usermod -aG sudo myuser

    If the second line fails, you have a partially created user with no password. You need to manually clean up and rerun. The script has no memory of what it was trying to accomplish.

    Debugging Declarative Code

    # What if this fails?
    resource "user" "myuser" {
      name  = "myuser"
      shell = "/bin/bash"
      groups = ["sudo"]
    }

    Terraform will tell you exactly what's wrong. Maybe the sudo group doesn't exist, or there's a permission issue. You fix the root cause, and Terraform applies the changes to reach the desired state.

    Common Imperative IaC Tools

    Imperative approaches are common in configuration management and automation:

    ToolTypeUse Case
    Ansible PlaybooksImperativeConfiguration management, orchestration
    Shell ScriptsImperativeQuick automation, system administration
    Chef RecipesImperativeConfiguration management
    Puppet ManifestsImperativeConfiguration management

    Ansible Example

    # Imperative: Installing and configuring Nginx
    - name: Install Nginx
      apt:
        name: nginx
        state: present
     
    - name: Start Nginx service
      service:
        name: nginx
        state: started
        enabled: yes
     
    - name: Copy configuration file
      copy:
        src: nginx.conf
        dest: /etc/nginx/nginx.conf
        mode: '0644'

    This playbook performs actions in order. If the service start fails, the playbook stops. You need to manually handle partial states.

    Common Declarative IaC Tools

    Declarative approaches are the foundation of modern infrastructure management:

    ToolTypeUse Case
    TerraformDeclarativeMulti-cloud infrastructure
    PulumiDeclarativeMulti-cloud infrastructure
    AWS CloudFormationDeclarativeAWS infrastructure
    Azure Resource ManagerDeclarativeAzure infrastructure
    Kubernetes manifestsDeclarativeContainer orchestration

    Terraform Example

    # Declarative: Installing and configuring Nginx
    resource "apt_package" "nginx" {
      name = "nginx"
    }
     
    resource "systemd_service" "nginx" {
      name = "nginx"
      enabled = true
      running = true
    }
     
    resource "file" "nginx_config" {
      content = templatefile("${path.module}/nginx.conf.tpl", {
        worker_processes = "${var.worker_processes}"
      })
      destination = "/etc/nginx/nginx.conf"
    }

    Terraform ensures all resources are in the desired state. It handles dependencies, idempotency, and state management automatically.

    When to Use Each Approach

    Choose Imperative When

    • You need fine-grained control over execution order
    • You're working with tools that don't support declarative models
    • You're automating complex workflows with multiple steps
    • You need to handle error recovery and partial states manually
    • You're scripting quick, one-off tasks

    Choose Declarative When

    • You want infrastructure to be self-healing and idempotent
    • You're managing complex, multi-cloud environments
    • You want to focus on desired state rather than implementation details
    • You need collaboration and version control of infrastructure
    • You want automated state reconciliation

    Real-World Example: Deploying a Web Application

    Let's look at a practical example of both approaches.

    Imperative Deployment Script

    #!/bin/bash
    # deploy-app.sh - Imperative deployment
     
    set -e
     
    echo "Building application..."
    npm run build
     
    echo "Creating deployment directory..."
    mkdir -p /var/www/myapp
     
    echo "Copying application files..."
    cp -r dist/* /var/www/myapp/
     
    echo "Installing dependencies..."
    npm install --production --prefix /var/www/myapp
     
    echo "Restarting application..."
    systemctl restart myapp
     
    echo "Deployment complete!"

    This script performs a sequence of actions. If any step fails, you need to manually clean up and rerun. It's brittle and hard to maintain.

    Declarative Deployment (Terraform)

    # deploy-app.tf - Declarative deployment
     
    resource "local_file" "build_artifact" {
      content  = filebase64("${path.module}/dist/bundle.js")
      filename = "/tmp/bundle.js"
    }
     
    resource "null_resource" "deploy" {
      triggers = {
        build_time = timestamp()
      }
     
      provisioner "local-exec" {
        command = "mkdir -p /var/www/myapp && cp /tmp/bundle.js /var/www/myapp/"
      }
     
      provisioner "remote-exec" {
        inline = [
          "npm install --production --prefix /var/www/myapp",
          "systemctl restart myapp"
        ]
      }
    }

    Terraform manages the deployment as a stateful resource. It tracks dependencies, handles idempotency, and provides clear feedback about what's changing.

    Hybrid Approaches

    Many teams use both approaches together. You might use declarative IaC for infrastructure and imperative scripts for deployment logic.

    Common Pattern

    # Declarative: Define infrastructure
    resource "aws_instance" "web_server" {
      ami           = var.ami_id
      instance_type = var.instance_type
      tags = {
        Name = "web-server"
      }
    }
     
    # Imperative: Deployment script
    resource "null_resource" "deploy_app" {
      depends_on = [aws_instance.web_server]
     
      provisioner "file" {
        source      = "./app"
        destination = "/tmp/app"
      }
     
      provisioner "remote-exec" {
        inline = [
          "cd /tmp/app && npm install && npm run build",
          "systemctl restart myapp"
        ]
      }
    }

    This hybrid approach gives you the best of both worlds: declarative infrastructure management and imperative deployment logic.

    Best Practices

    For Imperative Code

    • Keep scripts focused and modular
    • Handle errors explicitly
    • Use idempotent operations where possible
    • Document the execution order
    • Test scripts in isolation

    For Declarative Code

    • Focus on desired state, not implementation
    • Use variables and modules for reusability
    • Write clear, descriptive resource names
    • Test changes in a non-production environment
    • Review state changes before applying

    Migration Considerations

    Moving from imperative to declarative approaches requires a mindset shift. You'll need to:

    1. Stop thinking about steps and start thinking about states
    2. Learn the tool's state management capabilities
    3. Understand dependencies between resources
    4. Plan for idempotency in your designs
    5. Accept that the tool will do more work to achieve the same result

    Conclusion

    Both declarative and imperative IaC have their place. Declarative approaches have become the standard for infrastructure management because they provide consistency, reliability, and easier collaboration. Imperative approaches remain valuable for specific use cases where fine-grained control is needed.

    The key is understanding the difference and choosing the right tool for the job. When in doubt, start with declarative — it's generally more maintainable and less error-prone for most infrastructure scenarios.

    If you're managing complex deployments, consider a hybrid approach that combines both paradigms. This gives you the flexibility to handle edge cases while maintaining the benefits of declarative state management for the core infrastructure.

    Leave comment