Skip to main content

Refactoring ExecuteTerraform: From Cyclomatic Complexity 160 to 26

· 4 min read
RB
CEO @ Infralicious

When a single function accumulates 160 decision branches, it becomes nearly impossible to reason about, test, or safely change. That's exactly what happened to ExecuteTerraform in Atmos — the beating heart of every atmos terraform call. This post explains why we refactored it and what we learned along the way.

The Problem: A 760-Line Monolith

ExecuteTerraform was the largest single function in the Atmos codebase. Over time it had grown to handle everything from authentication setup and stack processing to workspace management, provisioner orchestration, plan-file cleanup, and status uploads — all inline, in a single sprawling function.

The cyclomatic complexity score was 160 — more than ten times the project's lint limit of 15. That meant:

  • No unit tests — the function required a real atmos.yaml, a real stack, real terraform binaries, and live AWS credentials just to call it.
  • Impossible to audit — anyone reviewing a security fix had to trace through hundreds of interleaved branches.
  • Fragile changes — adding a new flag required careful reading of the entire function to avoid introducing regressions.

The Approach: Extract, Name, Test

Rather than rewrite from scratch, we took a disciplined extraction approach:

  1. Identify logical boundaries — each block of related code became a candidate for a helper function.
  2. Name precisely — function names like setupTerraformAuth, checkComponentRestrictions, and assembleComponentEnvVars communicate intent immediately.
  3. Test every helper — because each function now has a clear signature and no side effects beyond its stated purpose, we could write fast, offline unit tests with no infrastructure.
  4. Apply DRY — the provisioner-setup logic that was duplicated between the early-init pre-step and the init subcommand path was unified into a single prepareInitExecution helper.

Helper Functions Introduced

FunctionResponsibility
resolveTerraformCommandPick terraform, tofu, or a custom binary
handleVersionSubcommandShort-circuit for version — no stack needed
setupTerraformAuthMerge global + component auth, create AuthManager, inject bridge
resolveAndProvisionComponentPathResolve path, auto-generate files, JIT-provision from source
checkComponentRestrictionsGuard abstract, locked, and HTTP-backend constraints
printAndWriteVarFilesLog and persist variable files
validateTerraformComponentRun OPA / JSON-schema policies
generateConfigFilesWrite backend config, provider overrides, generated files
warnOnConflictingEnvVarsDetect TF_CLI_ARGS / TF_WORKSPACE collisions
assembleComponentEnvVarsCompose the full subprocess environment
shouldRunTerraformInitDecide if a pre-init step is needed
buildInitArgsBuild init flag list (reconfigure, varfile)
prepareInitExecutionClean workspace + run provisioners + resolve workdir path
executeTerraformInitPhaseExecute the pre-init step
handleDeploySubcommandMap deployapply with auto-approve
logTerraformContextEmit execution-context debug log
buildPlanSubcommandArgsPlan-specific flags (out, upload-status)
buildApplySubcommandArgsApply-specific flags (varfile or planfile)
buildInitSubcommandArgsInit-specific flags (reconfigure, varfile)
buildWorkspaceSubcommandArgsWorkspace sub-subcommand argument
appendApplyPlanFileArgAppend positional plan-file to apply
buildTerraformCommandArgsOrchestrate all of the above for the main command
runWorkspaceSetupSelect / create the Terraform workspace
checkTTYRequirementFail fast when apply is called without a TTY
addRegionEnvVarForImportInject AWS_REGION for terraform import
resolveExitCodeExtract an integer exit code from an error
executeMainTerraformCommandRun the command, handle upload-status, propagate exit codes
cleanupTerraformFilesRemove ephemeral plan / varfiles

The Result

After the refactoring:

  • ExecuteTerraform complexity: 26 (down from 160)
  • buildTerraformCommandArgs complexity: 9 (the most complex helper)
  • 100+ new unit tests — all pure in-process, zero infrastructure required
  • Zero behavior changes — the entire existing integration test suite passes unchanged

Lessons Learned

Complexity limits are guardrails, not suggestions. A max-complexity of 15 sounds strict until you see what happens when it is ignored for years.

Test-driven extraction pays off. Writing tests for each extracted helper forced us to think about the contract of each function, which in turn exposed several subtle differences (e.g., the slightly different init-reconfigure logic for the early-init pre-step vs. the init subcommand path).

Naming is architecture. When you can give a 20-line block of code a precise 3-word name, you have found a real abstraction.

Get Involved

Check out the Atmos GitHub repository and feel free to open issues, contribute code, or share ideas. We welcome contributions from the community!