Skip to content

IaC Functional Design

The Arda-cards/infrastructure repository organizes its CDK code as a layered architecture with a single, strict dependency direction. This page defines the layers, their responsibilities, and the rules that keep the codebase reusable, instance-pluggable, and safe to evolve.

This is the structural document. Mechanical conventions used inside a single layer (the Configuration / Props / Built three-interface pattern, ExportKeys, publish(), naming) live in the infrastructure repository’s knowledge-base/cdk-construct-patterns.md and are referenced from here rather than duplicated.

1. The dependency direction (the central rule)

Section titled “1. The dependency direction (the central rule)”
script → instances → apps → stacks → constructs

A → B means A imports from / depends on B, never the other way round. So instances/ files import from apps/, but no file under apps/ ever imports from instances/. There are no cycles inside constructs/, and no cycles anywhere in this hierarchy.

PlantUML diagram

The arrow encodes a value chain. constructs/ define raw building blocks; stacks/ compose them; apps/ wire stacks together; instances/ pin the wiring to one specific deployment scenario; script/ is the entry point that ties an App definition and an instance value together and synthesizes or deploys.

LayerRepository directoryReusable?DefinesImports from
constructs/src/main/cdk/constructs/Yes — many uses per constructPer-construct Configuration interface, Built shape; CDK Construct subclassNothing app-specific (CDK + platform/)
stacks/src/main/cdk/stacks/Yes — many uses per stackPer-stack Configuration interface, CDK Stack subclass that composes constructsconstructs/, platform/, CDK
apps/src/main/cdk/apps/Yes — pinned by one or more instances (varies by App)Per-app Configuration (its own shape), App class wiring stacks togetherstacks/, platform/, CDK
instances/src/main/cdk/instances/No — pins values for one specific deploymentConcrete const values typed to an App’s Configurationapps/ (for the type), platform/ (for value sources)
script / toolstools/, top-level entries referenced by cdk.jsonNo — entry pointsNothing structuralThe App class + the instance value; ties them together; may orchestrate multiple Apps

platform/ (a sibling, not a layer in this hierarchy) holds cross-cutting constants and small adapters that any layer may read — domain names, locator types, 1Password reference helpers, etc. It is the only thing constructs/ is allowed to know about other than the AWS CDK itself.

3. Layer organization — how the codebase is grouped

Section titled “3. Layer organization — how the codebase is grouped”

Constructs are organized by the kind of resource concern they encapsulate, not by which App or Stack uses them:

  • storage/ — buckets, tables, secret stores
  • compute/ — Lambda, Fargate, EKS-adjacent
  • xgress/ — ingress and egress: CloudFront, NLB, API Gateway, custom domains
  • dns/ — Route53 zones, NS-delegation writers
  • secrets/ — Secrets Manager wiring, ESO bridges
  • iam/ — roles, policies, authorization primitives
  • observability/ — log groups, dashboards, alarms

Grouping by function (rather than by consumer) lets a new App pick up a construct without that construct having to know about it. A new App is allowed to import any construct; a construct may not know which Apps it ends up in.

Stacks live under stacks/ in subdirectories that mirror the runtime layering of the platform:

  • stacks/root/ — account-level bootstrapping; top-of-tree DNS (RootDnsStack).
  • stacks/corporate/ — Corporate-account resources (mail DNS for arda.ardamails.com, future HubSpot integration).
  • Platform-infrastructure stacks — shared infrastructure (VPC, IAM roles, baseline DNS) used by every partition in an account.
  • Platform-partition stacks — per-partition product runtimes (dev / stage / demo / prod), each consuming the platform-infrastructure outputs.

A stack name reflects the concern it owns; the directory it lives in reflects which runtime layer that concern belongs to.

Each App is a reusable definition; an instance pins it to a specific deployment scenario.

  • Root — the top-of-tree DNS / bootstrap App. Single instance per account family.
  • Product (Al1x and similar) — runtime Apps for product partitions. One App definition, many instances (dev, stage, demo, prod, kyle, …).
  • Corporate — Corporate-account Apps (mail, future HubSpot). One App definition, one instance today; potentially more if a separate Corporate-style account is later added.

App-by-role makes the Configuration the App exposes match the way operators reason about the platform — “deploy the Root App”, “deploy the Corporate App for Alpha001”, “deploy a Product App for the dev partition”.

3.4 Instances — by top-level resource aggregation

Section titled “3.4 Instances — by top-level resource aggregation”

There is one instance file per App, declaring the const values that pin that App. The granularity matches the App’s Configuration, not the asset within it. Instance files do not sub-divide per asset (no free-kanban-tool.ts next to mail-root.ts and a barrel index.ts when the App’s Configuration is a single shape — match the App).

Scripts under tools/ are organized by the operator function they serve: synthesis entry points (one per App, referenced by cdk.json), drift checks, operator CLIs, deploy runners, multi-App choreographies. Scripts are the only layer that may know about more than one App.

  1. Apps, Stacks, and Constructs are reusable abstractions. They are parameterized by their Configuration interface. A single App definition can be instantiated with different pinned values by different instances (the dev variant of the Product App and its prod variant share the same App class).

  2. Instances are the only place where deployment-specific decisions live. They pin one value of an App’s Configuration per actual deployment scenario. Anything that varies per environment that is not derivable from a value already at a lower layer belongs in an instance file.

  3. Scripts are the only place that calls new cdk.App() (or new MyApp(...)). Files under apps/ define App classes; they have no side effects at module load. This is what makes Apps composable inside larger choreographies. The same discipline extends to publish(): stacks expose publish() as a method but never call it from their own constructor — calling order is wired in apps/<App>/... after addDependency(). See the cross-stack export key reference for the mechanics.

  4. The App’s Configuration is its own contract, not a mechanical union of stack configurations. The App may:

    • Aggregate — passthrough union, when applicable.
    • Fix stack parameters internally — those parameters become App-level decisions, not user-facing knobs.
    • Derive stack parameters from cross-stack outputs or platform/ constants.
    • Project one App-level field into multiple stack-level values.

    The App’s Configuration is designed for its consumers (instance authors and script authors), not for the convenience of its stacks.

  5. Instance → App is 1 : 1; App → instances is 1 : N. Each instance file pins exactly one App (it imports that App’s Configuration type). An App, in turn, is pinned by one or more instances. Single-deployment Apps (e.g. Corporate today, Root) have one instance; product runtime Apps have many — the Platform-Partition App is pinned by dev, stage, demo, prod, kyle instances across two infrastructures (Alpha001, Alpha002), and gaining a new partition is solely the act of adding a new instance file. Reuse is the rule, not the exception.

  6. Script ↔ App is 1 : N. A script may instantiate multiple Apps in sequence — choreographed deployments, multi-app verifications, sequenced operator workflows. This is the only layer that gets to know about multiple App-shaped things at once.

  7. Type-system discipline. Each layer’s Configuration interface lives at that layer. Never define a stack or app shape in an instance file; never let a construct depend on stack or app types.

5.1 Root App — a stable, well-layered App

Section titled “5.1 Root App — a stable, well-layered App”
  • instances/Root/dns.ts declares the const values that pin the Root App for the canonical Arda account family (zone names, the ardamails.com import target, NS-delegation IAM role configuration).
  • apps/Root/ defines the Root App class and its Configuration interface. The App class composes the Root stacks; it has no side effects at module load.
  • stacks/root/RootDnsStack.ts composes DNS-family constructs (Route53 hosted zones, the WriteNSRecordsToUpstreamDns construct, the AllowCreatingNSRecordsRole IAM role).
  • The tools/ runner script calls new RootApp(instanceValue) and synthesizes / deploys.

5.2 Corporate App — a fresh App built to the same model

Section titled “5.2 Corporate App — a fresh App built to the same model”

The Phase 3 email-integration delivery introduces a Corporate App against this exact shape:

  • apps/Corporate/ defines CorporateApp with its own Configuration (Corporate parent zone, sender list, verification-target DNS records).
  • stacks/corporate/CorporateMailDns.ts and stacks/corporate/FreeKanbanToolMailDns.ts are composed by CorporateApp. Each Stack has its own Configuration shaped for its role.
  • instances/Corporate/corporate.ts is the single instance file pinning Corporate-App values for the canonical Arda Corporate account.
  • A small entry script under tools/ synthesizes and deploys the Corporate App; the same script can later call into the Postmark CLI thin-wrapper for the operator-driven domain-verification leg.

5.3 Platform-Partition App — one App, many instances

Section titled “5.3 Platform-Partition App — one App, many instances”

The Al1x Product App is the model case for App reuse:

  • apps/Al1x/ defines the App class and its Configuration.
  • instances/Al1x/dev.ts, instances/Al1x/stage.ts, instances/Al1x/demo.ts, instances/Al1x/prod.ts, instances/Al1x/kyle.ts each pin the App for a specific partition.
  • The tools/cdk-runner.js is data-driven over partition apps and selects the correct instance per CI matrix row.

The dependency-direction rule rules out a recurring set of mistakes. Each is wrong because it inverts an arrow or smears a responsibility across layers.

  • Defining a Configuration interface in an instances/ file. The shape belongs at apps/ or stacks/ — wherever the consumer of that shape lives. Instances are values, not types.
  • A stack importing from instances/. The arrow goes the wrong way. The stack is reusable; the instance is the pinned value. If a stack needs information that today only an instance carries, lift the field into the App’s Configuration and pass it down.
  • An apps/ file calling new cdk.App() at module load. The App class is reusable code; the script is the entry. Module-load side effects make Apps un-composable inside choreographies.
  • Per-asset instance files (free-kanban-tool.ts, mail-root.ts, index.ts) when one file would do. Match the App’s granularity, not the asset’s. If the App’s Configuration is a single shape, the instance is a single file.
  • Thin-wrapper constructs that take the whole cdk.App only to call app.node.tryGetContext(key). Pass the resolved value, not the App. Constructs do not need to know they live in a CDK App at all — they take parameters.
  • Treating the App’s Configuration as a mechanical union of stack configs by default. Design the App’s Configuration for its consumers; aggregating, fixing, deriving, and projecting are all available. The “union” approach leaks every internal stack decision out to the instance author.
  • Using cd <path> in shell helpers that touch this codebase. Use git -C <path> and make -C <path> to keep the shell cwd stable and avoid the permission prompts that interrupt agent and operator workflows alike. (See path conventions for the workspace-wide rule.)

Rules that are not enforced rot. Three practical mechanisms keep this layering honest, all live in Arda-cards/infrastructure:

  1. ESLint import/no-restricted-paths in eslint.config.mjs. Three zones forbid the wrong-direction imports captured in § 2: constructs/ cannot import from stacks/, apps/, or instances/; stacks/ cannot import from apps/ or instances/; apps/ cannot import from instances/. CDK App entry scripts (the only files that legitimately import instances/) live under tools/, which is the script layer and is therefore not subject to the apps-layer rule. The CI lint stage and make lint both fail on any wrong-direction import.
  2. Code-review checklist baked into .github/PULL_REQUEST_TEMPLATE.md. A new construct must declare which kind-of-resource concern it encapsulates (storage / compute / xgress / dns / secrets / iam / observability). A new Stack must declare its runtime layer (root / corporate / platform-infrastructure / platform-partition). A new App must declare its role (Root / Product / Corporate / other). The template is the gate; reviewers confirm the boxes during review.
  3. AWS-impact classification on every PR, also captured by the same template. Each PR ticks None, Synth-only, or Resource-touching. The layering helps the classification become mechanical: changes confined to constructs/ and stacks/ are typically synth-only by inspection; changes to instances/ and tools/ are the ones that produce different deployed states; resource-touching PRs require an explicit cdk diff review.
  • Section index — what else lives in the Infrastructure Patterns section.
  • Design Pattern Index — the catalog this section sits under.
  • Implementation Patterns — code-level patterns; the construct’s Configuration / Props / Built triad is the IaC counterpart.
  • AWS CDK Infrastructure — technology reference covering the CDK primitives and repo-local conventions used inside these layers: the Configuration / Props / Built pattern, cross-stack export keys, custom-resource provider framework, removal policies, cdk import choreography, CDK assertions, and common pitfalls.
  • The infrastructure repository’s knowledge-base/cdk-construct-patterns.md — repo-local mechanical conventions referenced throughout this page.