Refactoring ExecuteTerraform: From Cyclomatic Complexity 160 to 26
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:
- Identify logical boundaries — each block of related code became a candidate for a helper function.
- Name precisely — function names like
setupTerraformAuth,checkComponentRestrictions, andassembleComponentEnvVarscommunicate intent immediately. - 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.
- Apply DRY — the provisioner-setup logic that was duplicated between the early-init pre-step and the
initsubcommand path was unified into a singleprepareInitExecutionhelper.
Helper Functions Introduced
| Function | Responsibility |
|---|---|
resolveTerraformCommand | Pick terraform, tofu, or a custom binary |
handleVersionSubcommand | Short-circuit for version — no stack needed |
setupTerraformAuth | Merge global + component auth, create AuthManager, inject bridge |
resolveAndProvisionComponentPath | Resolve path, auto-generate files, JIT-provision from source |
checkComponentRestrictions | Guard abstract, locked, and HTTP-backend constraints |
printAndWriteVarFiles | Log and persist variable files |
validateTerraformComponent | Run OPA / JSON-schema policies |
generateConfigFiles | Write backend config, provider overrides, generated files |
warnOnConflictingEnvVars | Detect TF_CLI_ARGS / TF_WORKSPACE collisions |
assembleComponentEnvVars | Compose the full subprocess environment |
shouldRunTerraformInit | Decide if a pre-init step is needed |
buildInitArgs | Build init flag list (reconfigure, varfile) |
prepareInitExecution | Clean workspace + run provisioners + resolve workdir path |
executeTerraformInitPhase | Execute the pre-init step |
handleDeploySubcommand | Map deploy → apply with auto-approve |
logTerraformContext | Emit execution-context debug log |
buildPlanSubcommandArgs | Plan-specific flags (out, upload-status) |
buildApplySubcommandArgs | Apply-specific flags (varfile or planfile) |
buildInitSubcommandArgs | Init-specific flags (reconfigure, varfile) |
buildWorkspaceSubcommandArgs | Workspace sub-subcommand argument |
appendApplyPlanFileArg | Append positional plan-file to apply |
buildTerraformCommandArgs | Orchestrate all of the above for the main command |
runWorkspaceSetup | Select / create the Terraform workspace |
checkTTYRequirement | Fail fast when apply is called without a TTY |
addRegionEnvVarForImport | Inject AWS_REGION for terraform import |
resolveExitCode | Extract an integer exit code from an error |
executeMainTerraformCommand | Run the command, handle upload-status, propagate exit codes |
cleanupTerraformFiles | Remove ephemeral plan / varfiles |
The Result
After the refactoring:
ExecuteTerraformcomplexity: 26 (down from 160)buildTerraformCommandArgscomplexity: 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!
