cdktn-aws · v0.1.1 published · aws provider 6.62.0 · 2026-09-01

Grouped bindings, measured against the 500 MiB wall

Regrouping terraform-provider-aws from one jsii submodule per resource into one per service makes the library ~24× cheaper to cold-start, keeps the API surface byte-comparable — and is the only shape in which a Go distribution can exist at all after cdk-terrain#387.

Audience open-constructs maintainers Status v0.1.1 live — npm · PyPI · Maven · NuGet · 258 Go modules via proxy.golang.org, validated end-to-end Ask adopt grouped generation as a supported generator mode
−95.9%
JS cold start, top-level import (45.2 ms vs 1,100.0 ms)
30,714 = 30,714
types in the assembly, ours vs reference — identical surface
11.58%
of the Go cap: the largest of 258 split modules
99.89%
of the Go cap: an unsplit monolith, at its absolute floor
§1

The problem

go get of the prebuilt cdktn-provider-aws-go broke permanently at tag aws/v24.6.0. The Go toolchain, module proxy and checksum database all enforce a 524,288,000 B cap on a module's source tree (golang.org/x/mod/zip, MaxZipFile):

tagmodule source bytesoutcome
aws/v24.5.0523,366,927PASS last passing — 99.82% of the cap
aws/v24.6.0525,519,743FAIL first failing — 100.24%

Growth is ~1–2 MB per upstream provider minor. There is no client-side workaround for v24.6.0 and after: the cap lives in x/mod/zip, which is vendored into cmd/go itself — the proxy, the sumdb and the consumer's own toolchain all apply it, so no flag, vendor directory, private proxy or GOPRIVATE direct fetch restores it (a direct-mode go mod download fails identically). Every future monolithic Go release of this provider stays broken, and the margin only goes one way. Issue #387 proposed a per-service module split as the structural fix; this PoC is that fix, taken seriously.

§2

What was built

M0 — the grouping map. The aws provider doesn't encode its service in the resource name (aws_lb, aws_alb, aws_db_instance), so grouping has to come from outside the schema. The only machine-readable grouping upstream maintains is the hand-written subcategory: docs frontmatter. groups.json is that mapping, mined once and then curated by hand: 257 groups covering 100% of the aws 6.62.0 schema — 1,711 resources, 679 data sources, 10 ephemeral resources — no misc bucket, an explicit alias table, and two CI gates (coverage; group-moves as marked breaking changes). The miner is deliberately not wired into automation: an upstream editorial rename lands on us as a submodule rename.

M1 — the generator. tools/aws2cdk forks the awscc generator, which itself vendors cdk-terrain's @cdktn/provider-generator verbatim. One standalone jsii package per group; classes named PascalCase-of-the-full-terraform-type (AwsLambdaFunction, DataAwsLb); every nested block type mounted inside the resource class's merged namespace — which is what keeps struct-name collisions at zero and makes the 400-export structs<N>.ts sharding rule unnecessary. Everything the awscc fork had trimmed was restored verbatim from the vendored originals, and a runtime-contract diff against an unmodified vendored build is a permanent gate.

M2 — full generation and the monolith. All 258 packages (257 groups + provider): 2,401 classes, 9,856 nested property types, 2,660 .ts files / 84.7 MiB, emitted in ~3 s. The published npm/PyPI shape is one jsii assembly, @cdktn/aws: 39.5 s to compile, 0 JSII3 / 0 JSII6, a 150,147,226 B assembly against the reference's 150,318,168 B. generated/hashes.json carries a per-group content hash — the lever the release step uses.

M3 — the Go fleet. 258 sibling modules under github.com/cdktn-io/cdktn-aws-go, no root go.mod, built by one pinned jsii 5.9.53 + pacmak 1.140.0 in 105.3 s at 12-way; go mod tidy + go build 258/258. Isolation proven on the code pacmak writes: 68,402 .go files, 137,510 import specifiers, 0 cross-group imports, 0 root-level shared packages. Sizes measured with zip.CheckDir — the function go mod download itself runs. A 36-module consumer synthesises with validations on in 324 ms, asserting on the emitted cdk.tf.json.

M4 — publishing, for real. The two-pipeline release design (monolith → npm/PyPI/Maven/NuGet; fleet → per-group prefixed tags, changed-groups-only via the hash diff) has now executed: v0.1.1 is live on all four registries and all 258 <group>/v0.1.1 fleet tags resolve through proxy.golang.org with sumdb verification. The first release run flushed out one real bug the dry-run could not — the release version wasn't stamped through the Go fleet — fixed, with re-dispatch made idempotent.

§3

Headline metrics

Measured against @cdktn/provider-aws 25.3.0 on one machine (Apple silicon, 48 GiB, 18 cores). Full method, every command and every caveat: docs/m2-metrics.md.

@cdktn/aws · 258 submodulesreference · 2,402delta
JS cold start — top-level import + reach two services45.2 ms1,100.0 ms−95.9% (24.3×)
require.cache entries after that2062,572−92.0%
node RSS after that81 MB831 MB−90.3%
JS deep path (lib/<resource>)45.4 ms29.1 ms+56% — we lose
Python import + reach two services429.6 ms1,263.7 ms−66.0% (2.9×)
sys.modules if every submodule is touched8335,121−83.7%
Doc files (5 languages)1,290 projected12,015 measured−89.3%
.jsii assembly150,147,226 B150,318,168 B−0.1%
Types in the assembly30,71430,714identical
npm lib/ bytes312,316,455 B319,570,579 B−2.3%
Python wheel62,419,552 B69,098,649 B−9.7%

Read honestly, this table says three things:

  • Cold start is the win, and the lazify pass produces it. TypeScript compiles export * as lambda from './lambda' to an eager require; the reference does the same thing 2,405 times. Eager-vs-eager the two shapes are within 12%. The monolith build rewrites the compiled barrel into one self-replacing getter per submodule before pacmak runs, so the npm tarball embedded in the Python wheel carries the lazy barrel too (1 require against 2,402). That is the whole Python win — same mechanism, not a second one.
  • Python's number depends on the jsii cache regime, and all three were measured: −39.2% with the package cache disabled (high-variance; don't quote precisely), −66.0% cached with no type index (the state right after pip install; the headline), −87.1% / 7.7× once a type index exists. A superseded −9.1% figure in the repo's history was an eager pre-lazify wheel measured against a correct reference — an artifact of a build-order mistake, caught by a one-line grep -c 'require(' on the shipped wheel.
  • Bytes on a registry are a wash. Same types, same size. Grouping doesn't make the library smaller; it makes it cheaper to load and 9× cheaper to document.
The quantified cost of group independence

A shape-hash census finds 5,357 duplicate structs: 4,579 within a group and 778 cross-group — where deduplication is structurally forbidden, because a shared struct needs a shared module root and the zero-cross-group-imports gate exists to guarantee there is none. That guarantee is exactly what makes each group independently hashable, taggable and releasable as a Go module. 778 duplicated definitions is the price, and it is cheap.

§4

The Go verdict

bytes% of the 524,288,000 B cap
Sum of the 258 fleet modules (measured, zip.CheckDir)567,214,581108.2% summed, not a monolith
…minus per-module overhead a monolith would not pay−43,494,025258 embedded tarballs, LICENSE/README/version sets
Monolith floor523,720,55699.89%
M2 projection from seven packed groups (top of the range)644,538,536122.9%
Largest single fleet module (awslexv2models)60,722,73211.58%
Median fleet module (awslocation)956,8080.18%

The honest statement: an unsplit aws Go monolith lands somewhere from ~99.9% of the cap upward. 99.89% is a floor in the strict sense — the one unquantified adjustment (a single 2,401-class package must disambiguate flat-namespace collisions) can only push it up. At the low end this is a margin call rather than "undownloadable": the monolith would ship with a fraction of a percent of headroom on a tree that grows 1–2 MB per upstream minor, would cross on some near-future release, and would then have no recourse short of the split. That is a real argument for the split; it is not the stronger claim, and this report does not make the stronger claim.

The per-module gate is not a projection at all. All 258 modules are measured by the same function go mod download runs: 1 module ≥ 10% of the cap, 17 in 1–10%, 158 in 0.1–1%, 82 under 0.1%, 0 over. What the split buys, priced: a consumer of one group downloads the median 956,808 B — 60.7 MB in the very worst case — instead of the whole library.

Since v0.1.1, this is measured against the real registry, not a projection. The example consumer resolves all 36 of its cdktn-aws-go modules through proxy.golang.org with sumdb verification — go mod tidy in 41 s on a cold module cache, no go.work, no replace directives — builds in 9.3 s, synthesises in 2.3 s with provider validation on, and per-module first-touch cost lands at ~3 ms, matching the M3 measurements. All 35 group resource types appear in the emitted cdk.tf.json, with a provider-defined function (${provider::aws::arn_build(…)}) rendering in a real output.

Tag growth is answered, not hand-waved. 258 tags per release would be ~13k a year at weekly cadence. hashes.json + the release planner tag only the groups whose content hash moved; cadence is tied to upstream provider bumps; shard repositories remain a last resort. The rejected design (one whole-library core module) could do none of this — and its consumer paid 849 ms to load that core before constructing anything, against 6.2 ms per group actually imported here.

§5

What upstreaming would take

The home is packages/@cdktn/provider-generator/src/get/generator/**. The PoC's vendored tree is that code copied verbatim with one mechanical change, and it drives the reference side of a contract diff — so the delta below is exactly what would have to become supported behaviour rather than a fork.

  1. Grouping as an input — a group map (slug → resources / dataSources / ephemeralResources + aliases) with slug → submodule / npm-name / Go-package-name derivation and a pairwise-injectivity assertion on the Go names (stripping underscores is not injective).
  2. Per-resource namespace merging — nested property types mounted into the owning class's merged namespace instead of flat module scope; removes the 400-export sharding rule, proven safe at 4,647 exports in one 6.87 MiB file.
  3. Package-scope mapper-name resolution — class-name prefixing is not injective when one class name prefixes another (AwsWafv2WebAcl vs AwsWafv2WebAclRule); the fix iterates whole-package prefixes to a fixed point. This bug only appears at scale.
  4. Per-group standalone manifests — package.json / README / tsconfig per group, peer-deps mirrored, the jsii Go target block.
  5. Per-group content hashes — path-and-length-framed, machine-independent, the input to changed-group releases.
  6. The second build workspace and the lazify pass — the piece with the broadest value: provider-agnostic, the source of both the JS and Python cold-start wins, and more at home in core than in any provider repo.
  7. Fleet build and gates — sharded whole-fleet jsii + pacmak-go, Go isolation checking, the zip.CheckDir size gate.
  8. Release tooling — the hash-diff changed-group tag planner, dry-run-only by construction.

Stays provider-specific: groups.json and its curation record, the slug policy, the frontmatter miner, aliases and manual assignments. The mechanism generalises; the mapping does not — a provider that encodes its service in the resource name (awscc) needs no external map at all.

§6

Costs and open risks

  • Two jsii compiles of the same tree — 39.5 s monolithic (16 GB heap flag) plus 28.6 s across the fleet. Accepted deliberately: the Go workspace stays strictly additive, so the npm/PyPI build is untouched by the split.
  • 258 machine-generated manifests — three strings in each are permanent from the first Go tag: the npm name, targets.go.moduleName, targets.go.packageName. All 258 asserted, not sampled (1,812 assertions), rules restated in the test rather than imported from the generator.
  • 778 cross-group duplicated structs, by design (§3).
  • Tag growth, mitigated but not eliminated (§4).
  • The /vN event — at semver major ≥ 2, pacmak appends /vN to all 258 module paths at once; Go's import-path-is-identity rule offers no migration.
  • The hash's one machine-dependent input: schema key order. Every dump seen is key-sorted (Go's encoding/json), so this is inert in practice — but a differently-ordered dump would move all 258 hashes at once and falsely re-tag every module. The release planner warns loudly on "all N groups changed" for exactly this reason.
  • Resolved at v0.1.1: the release flow has now met the real registries. Both pipelines executed in CI — the monolith published to npm, PyPI, Maven Central and NuGet, and all 258 fleet tags fetch through proxy.golang.org with sumdb verification. Java and .NET, previously never packed, built and published cleanly from the 258-submodule assembly.
  • Deep-path imports regress (§3): grouping is one service per door, so lambda + s3 is 205 modules against the reference's 144 for two resources.
§7

Decisions taken

All three were made before the first tag, and are now permanent:

  1. The Go directory / packageName convention — aws + slug with underscores stripped (lex_v2_models → awslexv2models). It is the directory, the tag prefix and the last element of every import path — locked in by the 258 published <group>/v0.1.1 tags.
  2. The fleet versioning scheme — lockstep on the repository's own semver (not the provider version), shipped at v0.1.1.
  3. The repositories are real and published — cdktn-io/cdktn-aws and cdktn-io/cdktn-aws-go; the monolith on four registries, the fleet through the proxy, validated end-to-end on cdk-terrain#387.