A library is not an architecture
The library I shipped before the cut was clear.
Ten months ago, I built a Pulumi library called @mutinex/cloud-infra that solved local infrastructure pain before the was explicit. The goal was reasonable: standardise GCP naming, reduce IAM boilerplate, make stack outputs easier to consume, and give teams a typed set of infrastructure building blocks instead of raw provider resources.
The library had real work in it: metadata helpers, output recording, stack references, service account components, project components, networking helpers, Cloud Run wrappers, load balancer components, custom roles, Workload Identity, PAM, and an access matrix for IAM. It had tests. It had READMEs. It tried to make infrastructure feel like software.
That useful surface hid the missing cut.
I treated "infrastructure as software" as "build a better library" before I had named the architecture that library was supposed to protect. The code made many things easier to create while leaving open what should be created together, who owned the boundary, which direction dependencies were allowed to flow, and where a release should be refused before provider calls ran.
A helper library belonged in the system.
The mistake was asking it to do the architecture's job.
What I optimised first
The old README opens with the right-sounding promise: a comprehensive Pulumi library for GCP infrastructure, with helpers that enforce consistency, simplify IAM and cross-stack dependencies, and promote maintainable cloud environments.
That promise is attractive because it names real pain:
- resource names drift
- stack outputs become unreadable
- IAM is repetitive and easy to get wrong
- GCP project bootstrap has awkward ordering
- service identities and API enablement have propagation edges
- every Pulumi program re-learns the same provider details
The engineerable move was a package.
The package exported a catalogue of components: accounts, roles, Workload Identity pools, buckets, databases, secrets, repositories, Cloud Run services, jobs, instances, load balancers, backend services, projects, subnets, connectors, NAT, tags, folders, and PAM.
The catalogue looked productive, and it quietly cut the system by provider nouns.
Those categories answer different questions. Provider categories answer "what API am I wrapping?" Ownership tiers answer "who applies this, who repairs it, and what can break when it changes?" A load balancer component can be well written and still be used from the wrong tier. A project component can correctly attach Shared VPC and still let a workload create the boundary it should only consume. A reference helper can throw when an output is missing and still leak the producer's internal layout as the consumer's API.
I missed that difference.
Output structure is not a contract
The old output manager recorded resources under a nested shape:
This was better than exporting random top-level values. A consumer could ask for an account email or a bucket ID without memorising the exact output name. It reduced stringly-typed drift.
The reference helper looked like this in use:
const baseInfra = new CloudInfraReference({
stack: 'organization/base/prd',
domain: 'au',
})
const networkId = baseInfra.getId('network', 'default')
const servicesSubnet = baseInfra.get('subnet', 'services')The consumer is still reaching into another stack by stack name, domain, resource type, and grouping key. The wrapper can validate that network/default exists, but it cannot say what capability the consumer is asking for.
The later version of this idea is different:
The syntax change is small; the boundary change is not. network/default is a backing object. environment/network.primary is a producer-owned capability. NetworkRef can carry the semantics the workload needs: which network, which subnets are attachable, what reachability class they imply, and which context they belong to.
The old helper made the path nicer while leaving it in the public interface.
The strongest signal was the escape hatch: ReferenceWithoutDomain. It was built for stacks whose outputs did not fit the structured shape, so a consumer could read root[name] directly and convert a string into an email/member-shaped object.
It was practical, and a confession: the abstraction had met infrastructure that did not share its model, and rather than require the producer to publish a contract, it added another read path. Once documented and exported, that path becomes an API for skipping the contract.
The missing type was semantic
The old README used the language of strong typing. That made sense in Pulumi: if a component returns pulumi.Output<string> for an email, a caller gets better editor support and fewer spelling mistakes.
For infrastructure, the missing type is semantic:
- this is a network a workload may attach to
- this is a deploy identity allowed to mutate this project
- this is a DNS zone where this workload may write these record types
- this is a backend the edge tier may route to
- this is a secret prefix this principal may read or update
The old library mostly typed provider properties. It could tell a consumer that getEmail() returns a string. The trust assumption, tier ownership, and caller authority to bind that identity to the target surface stayed outside the type.
That confusion let typed access stand in for safe composition.
A wrapper around StackReference becomes useful when it validates producer, key, context, expected type, and ownership. Without those fields, it mostly gives a safer way to read someone else's internals.
IAM became flexible before authority was explicit
The access matrix was the clearest example of the wrong layer doing too much.
The idea was good: IAM rules are noisy, and the same principal needs a set of roles across resources. The matrix let a caller define use cases, merge principals from Pulumi config, case-level settings, and rule-level settings, then create IAM members for projects, folders, service accounts, buckets, Cloud Run, secrets, subnets, repositories, and instances.
It supported patterns like:
new CloudInfraAccessMatrix({
'shared-project-owner': {
principals: [ghaAccounts.getAccount('org-prd-gha')],
rules: [
{
resource: orgProject,
role: 'roles/owner',
},
],
},
})It was convenient, and too powerful to be the architectural boundary. The matrix knows how to attach a role to a resource. Authority still sits outside it: whether this tier may mutate that surface, whether the principal is the holder of an explicit binding rule, whether the operation is allowed, and whether the resource exists in the right release context.
The later rule for shared surfaces is stricter:
That rule says more than "grant this role". It names the surface, the request shape, the allowed operations, and the principal allowed to exercise them. The deploy path can refuse before it calls the DNS provider.
The old access matrix could help write IAM after the decision. The decision had to happen before the matrix wrote IAM.
There were smaller symptoms too. Invalid principal config could be warned about and treated as an empty list. Role names that could not be resolved at preview needed a manual label, otherwise the generated Pulumi resource name fell back to values like role-0. Resource names could fall back to unknown-resource after trying component getters and Pulumi internals.
Those details are generic-engine symptoms. A tool that supports too many caller shapes starts guessing and smoothing over ambiguity. Infrastructure has to refuse ambiguity earlier than that.
The project component almost had the right idea
The CloudInfraServiceProject component is a useful example because it is close to the target model.
It creates a GCP service project, enables APIs, attaches it to a Shared VPC host project, creates service identities, and grants host-project IAM for network access. That is real environment-tier work. A service project is the boundary a workload should receive before it creates its runtime resources.
The rule lived in the wrong place.
In the target shape, the environment tier creates the workload boundary and publishes an assigned project reference:
environment publishes:
serviceProjects["workload-api"] = {
projectId
deployMember
keyId
artifactRegistry?
frontend?
}
workload consumes:
getServiceProject(envRef, "workload-api")The workload never creates its own project. It does not discover projects by labels. It does not get organisation-level authority so it can bootstrap itself. It receives an assigned boundary and creates workload-owned members inside it.
The old component allowed that use, while enforcement lived elsewhere. It was a constructor exported from a broad library. Any stack with the right config could call it. The tier rule lived in convention, not in the release path.
Component correctness still needs a producer surface above it: a Cloud Run wrapper needs a workload-owned release path; a load balancer wrapper needs an edge contract when routing is shared; a secret wrapper needs binding rules on prefix and operation when the store crosses tiers.
The library supplied the components. The architecture around them was still implicit.
The Cloud Run plus NEG wrapper blurred edge
The Cloud Run service component created a Cloud Run service and a regional serverless NEG alongside it. That made load-balancer integration easier. It also quietly joined two lifecycles:
When one team owns the service and the edge policy at the same cadence, creating both together is reasonable.
But in the system I was moving toward, edge became its own tier because routing, certificates, and shared load balancers had a different blast radius. One backend change pulling the shared edge stack into the application release means edge ownership has leaked, and one routing-only change previewing the whole environment means the cut is too wide.
The old wrapper blurred that distinction. It created an attachable edge object as a side effect of creating the runtime service. That is convenient at the resource level and ambiguous at the ownership level.
The cleaner shape is to publish a workload backend contract and let edge consume it after the workload exists:
The NEG helper still has a job, but the public boundary has to be "this workload publishes a backend the edge tier may route to" rather than "this component happens to create a NEG". A workload publishes its static frontends as staticBackends, the static counterpart to the dynamic services above.
Why I made this mistake
The mistake was understandable.
The existing infrastructure was already painful. There were shared projects, copied outputs, broad deploy identities, service accounts near the wrong module, DNS records where they were convenient to write, and enough provider-specific behaviour that every change felt like it needed a helper.
A library is a tempting response to that pain.
It gives immediate wins: standardised names, less repetition, and hidden GCP bootstrap awkwardness. A package, tests, and a component catalogue make the platform look consistent.
Architecture work is slower. It starts with uncomfortable questions:
- Which owner applies this tier?
- Which team is allowed to break this contract?
- Which direction does this dependency cross?
- Would this member still exist if the workload disappeared?
- Is this a one-off exception, or a missing producer surface?
- Where does the release path refuse invalid composition?
Those questions do not feel as shippable as a component. They produce fewer lines of code. They also force decisions that people can disagree with.
So I built the part I could control.
The library was an attempt to pull order out of a messy system without first freezing the shape of the system. It worked on local consistency and postponed global ownership.
What was actually violated
The violations are clearer now:
Ownership was implicit. The package exported resources and helpers without the authority model. A component could be used by the right tier or the wrong tier. The code rarely made that distinction impossible.
Dependency direction was unprotected. Stack references made reads easier, but a consumer could still know the producer stack name, output shape, resource type, grouping key, and property. That is a nicer dependency while leaving direction unprotected.
Contracts were resource-shaped.
gcp:compute/network:Network names a different thing from environment/network.primary. One names a resource. The other names a producer-owned capability with meaning.
Shared-surface mutation was treated as IAM plumbing. The access matrix could grant roles. The missing model was admitted request shape, operation, holder, and refusal before provider calls.
Release order was outside the abstraction. The library could create resources, but release waves lived outside it: organisation first, environment next, edge after that, workloads on their own path. Without that, a correct component can still be applied in the wrong composition.
Migration was implicit. The output and reference layers did not encode "publish new contract beside old path, move consumers, block new reads, then delete." That order lived in human memory.
So the result was mixed. The library reduced some errors while preserving the conditions that made the larger mistakes possible.
What I would keep
Some parts still earn a place.
Thin components are useful when they stay below the architecture. Naming helpers, GCP project bootstrap helpers, and small wrappers around awkward provider resources still pull their weight. Output helpers work when they publish a producer-owned contract instead of a resource inventory.
The useful parts need a smaller job. A library should make the approved shape boring to implement, the one the architecture has already chosen, instead of making every infrastructure shape easy to create.
That changes the design: the cross-tier API becomes narrow and named by capability rather than a catalogue of every resource, and consumer helpers refuse with named contract errors instead of resolving something plausible.
Shared surfaces have to carry binding rules:
A component library can still exist underneath that. It stops being the architecture.
The bigger lesson
I built the library because the infrastructure was hurting, and because a library looked like the disciplined response. Some of that discipline was real. The repo had useful instincts: reduce copy-paste, name things consistently, make outputs less chaotic, test the boring paths, stop every stack from hand-rolling the same provider details.
But I overestimated what those instincts could buy without a cut.
The missing work was deciding what changed together, who owned that change, which contracts were public, and where invalid composition stopped. Once those decisions are explicit, a library can make the chosen path easy. Before those decisions are explicit, a library can make the wrong path easier too.
I would name that earlier now: the shape first, then the helpers — the order the Making IaC boring series works through.