jason duffett.

Lose the constructs. Keep your composure.

tech 10 min read

1. A better way, by example

In Your infrastructure isn’t an app. So why is your CDK? I argued that CDK’s most familiar frustrations all trace back to one design choice. Think of the props threaded through five constructors, the stacks you can’t split without a fight, the base class you extended because there was nowhere else to put the behaviour. They’re symptoms of one cause: a Construct builds itself in its constructor, so to describe your infrastructure you have to run a program that constructs it. There’s no plain value you can read, diff, or hand to a colleague.

Experienced teams avoid much of this with discipline and convention; my argument is that CDK’s defaults make the good architecture the harder path, and composureCDK makes it the path of least resistance. And it does so without leaving CDK behind: composureCDK keeps the language, the L2 constructs, and the whole ecosystem. It changes only the part I wanted to change: how you describe the system before any of it runs. You get the conciseness of a declarative structure and the clarity of explicit dependencies, in a project that still reads as CDK to anyone who already knows CDK.

Enough hand-waving. Here’s a whole system as a single value. It stands up a small website: an S3 bucket behind a CloudFront distribution, an ACM certificate, Route 53 DNS, and a health check to confirm the thing’s actually up. It’s a trimmed-down cut of the CDK that ships this very page, so it’s not a toy (source):

// app.ts
compose(
  // Components — a flat map of named builders.
  {
    // DNS
    zone: createHostedZoneBuilder().zoneName(domain),
    aliasRecords: zoneRecords([
      ALIAS("@", cloudfrontAliasTarget(ref("cdn", (d) => d.distribution))),
      ALIAS("www", cloudfrontAliasTarget(ref("cdn", (d) => d.distribution))),
    ]).zone(ref("zone", (z) => z.hostedZone)),

    // Certificate — DNS-validated against the zone
    cert: createCertificateBuilder()
      .domainName(domain)
      .subjectAlternativeNames([www])
      .validationZone(ref("zone", (z) => z.hostedZone)),

    // Site
    bucket: createBucketBuilder(),
    cdn: createDistributionBuilder()
      .domainNames([domain, www])
      .certificate(ref("cert", (c) => c.certificate))
      .defaultRootObject("index.html")
      .origin(ref("bucket", (b) => S3BucketOrigin.withOriginAccessControl(b.bucket)))
      .defaultBehavior({ viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS }),
    deploy: createBucketDeploymentBuilder()
      .sources([Source.asset("../site/dist")])
      .destinationBucket(ref("bucket", (b) => b.bucket))
      .distribution(ref("cdn", (d) => d.distribution))
      .distributionPaths(["/*"]),

    // Uptime health check on the public apex
    health: createHealthCheckBuilder().type(HealthCheckType.HTTPS).fqdn(domain),
  },
  // Dependencies — as data.
  {
    zone: [],
    aliasRecords: ["zone", "cdn"],
    cert: ["zone"],
    bucket: [],
    cdn: ["bucket", "cert"],
    deploy: ["bucket", "cdn"],
    health: [],
  },
).build(stack, "Site");

2. Walkthrough — reading the example

The first thing you notice is that this is a clear declaration of the system’s infrastructure: no misdirection, and no need to trace through levels of constructs to grasp the high-level architecture. It declares the DNS zone (zone) and records (aliasRecords), the site’s certificate (cert), its asset store (bucket), distribution (cdn), deployment (deploy), and health checker (health) right there in front of you. You also see each component’s dependencies laid out as a second map.

Each of those components is a Lifecycle: composureCDK’s minimal contract for something that can be built. compose takes the map of named Lifecycles, together with the dependency map, and assembles them into a single Lifecycle of its own. That’s why Lifecycle is the spine of the composure architecture — because a composed system is itself a Lifecycle, systems nest as components inside larger systems.

The second thing you’ll notice (I’m guessing) is the Builders. composureCDK uses the Builder Pattern to express the underlying CDK constructs. This has many advantages, but most importantly it separates the declaration of a component’s configuration from the construction of the component itself.

And finally, you’ll notice the ref that glues components to their dependencies.

For this simplified example, I’ve pushed everything into a single Stack. We’ll talk more about Stack management in a future article.

Now let’s drill down into the four core shapes we’ve identified.

3. The shapes

Lifecycle — good posture, by design

Lifecycle is the contract every component implements: a single build(scope, id, context) method that creates its CDK constructs and hands them back. It is deliberately minimal — one method, no base class to extend, no super() to call; a component is anything with a matching build.

interface Lifecycle<T, Context> {
  build(scope: IConstruct, id: string, context?: Context): T;
}

compose — a system is a value

compose assembles components (which are Lifecycles) into a system, itself also a Lifecycle.

When compose is called, it:

  1. Builds a directed acyclic graph from the dependency declarations.
  2. Validates that the graph has no cycles. If a cycle is found, a CyclicDependencyError is thrown immediately.
  3. Returns a new Lifecycle whose build method topologically sorts the graph and builds each component in dependency order, passing the resolved outputs of its dependencies as context.

The eager validation is a big win for CDK projects. Catching cyclic references this way surfaces errors earlier, and with better diagnostics, than at synthesis time.

Because the composed system returned by compose is also a Lifecycle, it can also be used as a component in a larger system. Composition is recursive — systems can be nested without special handling.

function compose<Components extends Record<string, Lifecycle>>(
  components: Components,
  dependencies: { [Property in keyof Components]: Dependency<Components> },
);

The fluent builder — intent, not mutation

composureCDK exposes CDK constructs through fluent builder APIs. The shape buys a few things:

To avoid re-declaring the entire aws-cdk-lib API surface, builders are proxies over their underlying construct’s props. The footprint stays small, and each builder automatically inherits whatever the peer aws-cdk-lib version exposes.

ref — lazy wiring

Lifecycle, Builder, and compose each solve a distinct problem. But there is a gap between them: builders are configured before their dependencies are built. Ref<T> lets us capture a reference at configuration time that resolves at build time and its partner Resolvable<T> signposts the sites where a lazy reference can be used.

You can see this in the code snippet where the ACM CertificateBuilder has the member validationZone(Resolvable<acm.IHostedZone>). The system’s declaration glues this with a ref. The snippet above uses composure’s concise shorthand; annotate the callback parameter and the same wiring is fully typed:

ref("zone", (z: HostedZoneBuilderResult) => z.hostedZone);

where:

With the parameter annotated, TypeScript checks the transform end to end: z.hostedZone must exist and must satisfy the Resolvable<acm.IHostedZone> that validationZone expects, so a mistyped property or the wrong shape is a compile error. The names you wire with are checked too, in two different ways. A dependency in the second map is keyof the component map, so a typo like cdn: ["bukcet"] won’t compile. The component name inside a ref is a plain string, resolved when the system is built — so a typo there fails fast with a clear error at build time, rather than passing silently.

This is how cross-component wiring stays declarative instead of post-build glue.

4. “But an agent writes my CDK anyway”

That’s all very nice, Jason, but why do I care? I’m a 100x vibe-coder and agents do all my coding!

Fair. So let’s assume an agent writes all of it. That makes the case for composure stronger, not weaker.

Hand-drawn editorial cartoon: a developer reclines in a deck chair with a drink and a closed laptop beside them, while a friendly robot lays bricks labelled S3, Lambda and Queue into a slightly crooked wall — building their cloud infrastructure for them.
The agent lays the bricks. The architecture is still yours.

LLM coding agents like Claude Code are improving at a remarkable pace, but some limitations in how they work are already clear. They behave like a capable but pressured engineer: they read just enough to start, then follow whatever patterns they find in the slice of the codebase they’ve loaded. It’s fast, and in a small field of view it looks good: the local code is neat and readable. Zoom out, though, and it is often not architecturally sound. Personal experience and a growing body of research agree the costs land on the maintainability and coherence of a codebase, for humans and agents alike.

Problem 1: Limited context. An agent reasons over a bounded context window and reads only just enough to start. Tight coupling, like inheritance, and opaque API surfaces (a CDK Construct is a fine example) force it to drag a swathe of the codebase into context just to understand one function, diluting the signal it needs with noise. And context is finite: every token spends the model’s attention budget, and recall degrades as the window fills.

Problem 2: Patterns compound. Agents build on what already exists. An inheritance tower begets another level; props get threaded through one more constructor; each session adds a little opacity. Thoughtworks names the mechanism plainly: drift “compounds as agents and humans replicate existing patterns, including degraded ones, creating a feedback loop where poor code begets poorer code”. Whatever your codebase rewards, you get more of.

Problem 3: Conventions aren’t constraints. The usual defence against all this is a convention: a comment, a docstring, a line in CONTRIBUTING.md that says “register the dependency here.” But a convention is text, and an agent treats it as text: a suggestion it can quietly ignore while still producing code that compiles and runs. Only something the type system or the build enforces is a boundary it cannot cross. Leave an illegal state merely discouraged and the agent will still write code that reaches it; make that state unrepresentable and the option is gone.

Ok, yeah. But how does composureCDK help?

It’s declarative, not imperative. A compose system is declared: every component, and every dependency, laid out as data in one place. The agent gets the high-level architecture without spelunking through constructors to reconstruct it. Research comparing a declarative vs imperative user interface with computer-use agents measured a 67% jump in success rate and a 43.5% drop in error-prone interaction steps. Describe the what instead of a program that builds it, and there’s far less for the agent to get wrong.

It’s local and loosely coupled. The whole description lives in one value, so there’s less to assemble in-context and less to hallucinate when it can’t. A component’s coupling is spelled out in the dependency map, not hidden in a constructor, so its context carries more signal and less noise, and re-use beats duplication. One controlled study found agents on cleaner code used 7–8% fewer tokens and revisited files 34% less often.

It enforces, rather than suggests. A convention can be ignored; the dependency map can’t. To connect one component to another you declare the dependency and reach it through a ref. There’s no implicit way to couple them, so coupling stays explicit: if two components are connected, the map says so.

5. Conclusion + what’s next

I’ll be honest about the limits. This is a young project, and the larger claim, that a composure codebase stays coherent as it grows, is still to prove. But my experience so far is that the paradigm shift, moving from a program that builds a description to a description you can read and diff, is proving to be a satisfying and effective way to describe AWS infrastructure.

In the next article, we’ll talk about one of the other pillars of composureCDK: infrastructure that is secure and operationally mature — out of the box.

References

If you want to go deeper into any of the above topics, or are curious to know more, I recommend the following links: