---
title: "A library is not an architecture"
canonical_url: https://ruslanakchurin.dev/blog/library-not-architecture
description: "The Pulumi component library I built made infrastructure easier to create before ownership, tiers, contracts, and release boundaries were explicit."
datePublished: 2026-07-07
dateModified: 2026-07-07
tags: ["infrastructure as code","software architecture","ownership boundaries","platform engineering","pulumi"]
about: [{"name":"Separation of concerns","@type":"Thing","sameAs":"https://www.wikidata.org/wiki/Q2465506"},{"name":"Coupling","@type":"Thing","sameAs":"https://www.wikidata.org/wiki/Q253448"}]
mentions: [{"name":"Pulumi","@type":"SoftwareApplication","sameAs":"https://www.wikidata.org/wiki/Q138634162"},{"name":"Google Cloud Platform","@type":"SoftwareApplication","sameAs":"https://www.wikidata.org/wiki/Q17054505"},{"name":"Identity management","@type":"Thing","sameAs":"https://www.wikidata.org/wiki/Q977772"}]
---
# 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`](https://github.com/ruslanakchurindev/mutinex-cloud-infra) that solved local infrastructure pain before the {{ownership model | Who applies each tier, who may break a published contract consumers rely on, and which direction dependencies cross. Those decisions are the shape the [Making IaC boring](/blog/making-iac-boring) series cuts first, before any helper code. | Ownership model}} 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.

```svg path="standalone/diagrams/01-provider-vs-ownership.diagram.ts" width="560"
library surface:
  IAM
  Data
  Compute
  Network
  Organisation

architecture question still open:
  organisation
  environment
  workload
  edge
```

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:

```svg path="standalone/diagrams/02-output-inventory.diagram.ts" width="460"
domain
  resource type
    grouping key
      id
      name
      email
      location
      projectId
      ...
```

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:

```ts
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:

```svg path="standalone/diagrams/03-capability-contract.diagram.ts" width="480"
consumer: workload-api
requires: environment/network.primary
expects:  NetworkRef
resolves in: current release context
```

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 <span class="accent">require the producer to publish a contract</span>, 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:

```ts
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:

```svg path="standalone/diagrams/04-binding-rule.diagram.ts" width="520"
binding rule: organisation/dns.workload-api-zone
  admits:  *.api.example.com
  permits: A | CNAME | TXT
  ops:     CREATE | UPDATE | DELETE
  holder:  workload-api/deploy
```

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. <span class="accent">Infrastructure has to refuse ambiguity earlier than that.</span>

## 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:

```text
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:

```svg path="standalone/diagrams/05-edge-blurred.diagram.ts" width="460"
workload concern:
  Cloud Run service
  runtime service account
  image
  env vars

edge concern:
  backend reference
  routing
  certificates
  shared load balancer policy
```

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:

```svg path="standalone/diagrams/06-published-backend.diagram.ts" width="460"
workload publishes:
  services["workload-api"] = {
    backendId
    region
    health
  }

edge consumes:
  getServiceBackend(workloadRef, "workload-api")
```

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:

```svg path="standalone/diagrams/07-binding-rules.diagram.ts" width="560"
Three shared surfaces — dns, secrets, edge — each carry a binding rule. The dns rule declares an admitted name pattern, record types, operations, and a holder; the secrets rule a prefix, operations, and a holder; the edge rule a route pattern, a backend contract, and an owner. Every rule names a holder and refuses everything outside its admitted shape before the provider call.
```

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](/blog/making-iac-boring) series works through.
