When repeated Pulumi code earns abstraction and when it doesn't
We don't abstract repeated Pulumi code just because it shows up more than once. We do it when the shared shape is real, the behavior is stable enough to deserve a boundary, and the result is easier to read than the duplication it replaces.
On this page
The default
We don’t abstract repeated Pulumi code just because it repeats.
Some repetition is fine. Early duplication is usually cheaper than locking the wrong abstraction into a shared boundary and dragging it around for the next year. Two similar resources don’t automatically deserve a helper. Three similar resources don’t automatically deserve a component. Repetition only starts to matter when the shared shape is real enough that the abstraction makes the code easier to read, safer to change, and cheaper to maintain.
Abstraction doesn’t remove complexity. It moves it. Sometimes that’s exactly what you want. Sometimes it just means the messy part got buried behind a cleaner import.
Repetition is only the signal
Repeated code tells you to look closer. It doesn’t tell you what to do.
A lot of bad abstractions start the same way. Somebody sees the same resource pattern two or three times, extracts it, adds a few options, and calls it reusable. Then the real system shows up.
One Dataplex zone needs extra IAM for a customer service group. Another needs different dataset access and a different role mix. A third needs labels, naming, and policy tags that mostly overlap but not quite. Now the “shared” wrapper has booleans, optional arrays, overrides, and escape hatches nobody understands without opening the implementation anyway.
The duplication is gone. The confusion is new.
So we don’t treat “this appears more than once” as the decision. The real question is whether the shared shape is stable enough that pulling it behind a boundary will clarify the system instead of compressing a bunch of differences into one place.
What actually earns abstraction
Repeated Pulumi code starts earning abstraction when a few things are true at the same time.
First, the repeated infrastructure is doing the same job, not just looking similar from a distance. A Cloud Run service pattern with common service account wiring, secret access, logging defaults, alerting, and ingress rules may deserve abstraction. Two services that both happen to use Cloud Run but have different runtime assumptions, different IAM exposure, and different operational needs usually don’t.
Second, the shared behavior is stable enough that the boundary will hold for a while. If the shape is still moving, extraction is usually early. You don’t want to freeze a concept into a helper or component before you’re sure the concept is real.
Third, the abstraction makes the calling code easier to read than the inline version. If it hides what matters, pushes important behavior into defaults nobody remembers, or forces reviewers to jump between files just to understand what gets created, it isn’t helping.
Shorter code isn’t always clearer code.
What usually stays inline
We usually keep code inline when the repetition is still shallow, when the differences between uses matter more than the similarities, or when the pattern hasn’t settled yet.
That’s common early in a system, especially in directory per environment setups where explicit duplication is often still the cheaper choice. One service needs slightly different networking. Another has different secrets and egress. A third is headed toward a different runtime model entirely. Pulling those into one shared wrapper too early usually creates a fake common layer that exists mostly to argue with reality.
The same thing happens with IAM. Three dataset bindings may look similar at first, but one has a Dataplex role, one has a BigQuery data viewer role, and one needs a service account plus a human group plus conditional access. You can absolutely hide that behind a helper. That doesn’t mean you should.
Inline duplication is often easier to review than a premature abstraction. At least the behavior stays visible where it’s used. The cost is obvious. The abstraction cost usually isn’t. That tends to show up later as flags, strange defaults, and calling code that looks simple only because the difficult part got buried somewhere else.
What bad abstraction looks like
Bad abstraction usually does one of three things.
It hides important behavior behind a neat interface. The call site looks clean, but the real work is buried in the helper or component and nobody can tell what gets created without opening another file.
Or it grows an interface full of options because the underlying pattern never really converged. Now it technically supports lots of cases, but reading the arguments feels like operating a broken control panel.
Or it centralizes code that was never really common. The abstraction becomes a compromise layer between slightly different needs, and every new use case makes the compromise worse.
This is also where what goes in Pulumi stack config and what doesn’t starts to matter. Once an abstraction gets too generic, teams often start pushing behavior into config and flags just to keep the wrapper alive. That’s usually a sign the boundary was wrong from the start.
What good abstraction buys
Good abstraction makes repeated infrastructure easier to understand at the call site.
It gives the repeated pattern a real name. It keeps common defaults in one place. It centralizes wiring that should stay aligned. It reduces copy-paste without making the system more mysterious. And it makes future changes cheaper because the shared behavior is genuinely shared, not just visually similar.
A good example is a stable service pattern. If every internal service needs the same service account structure, the same secret access model, the same logging defaults, the same alerting hooks, and the same baseline IAM, then giving that pattern a name is useful. The abstraction tells the reader what this thing is supposed to be.
Same for something like repeated Dataplex access wiring. If you really do have a stable pattern for “grant this group access to datasets in this zone with these bindings and these labels,” that can be worth abstracting. Not because it saves lines, but because it names a repeated responsibility and keeps the common behavior aligned.
The gain only shows up when the abstraction makes the program more legible, not when it merely makes the repo look tidier.
A quick test before pulling code behind a boundary
Before we abstract a repeated pattern, we want to answer a few simple questions without getting vague.
What is the repeated thing actually called?
What responsibility does it own?
Which behaviors are genuinely shared, and which ones are still local?
Will the call site be easier to understand after extraction?
Would somebody new to the repo be able to read the name and mostly infer what it creates?
If those answers are fuzzy, the abstraction is probably early.
If the only reason to extract is “this code looks repetitive,” that usually isn’t enough. Repetition is cheap. Bad abstraction isn’t.
When we usually abstract
We usually abstract repeated Pulumi code when the pattern is already stable, the behavior should stay aligned across multiple call sites, and the abstraction gives the pattern a name that makes the program easier to read.
That often happens with service patterns, common IAM shapes, shared networking blocks, repeated application wiring, or stable wrappers around things like Cloud Run services, service accounts, Dataplex bindings, or dataset access patterns that aren’t changing much between uses anymore.
By that point the abstraction isn’t guessing what the shared boundary might become. It’s naming one that already exists.
When we usually don’t
We usually don’t abstract when the pattern is still moving, when the differences between uses matter more than the similarities, or when the shared interface would need too many flags to stay useful.
We also don’t abstract just to make the repo look cleaner. That’s how teams end up with wrappers that satisfy taste more than they help delivery.
And we don’t abstract when keeping the code inline makes review easier. Sometimes the honest version of the system is a bit repetitive. That’s fine. The goal isn’t maximum reuse. The goal is clear behavior and cheap change.
The decision rule
We abstract repeated Pulumi code when the boundary is stable and easier to understand than the duplication it replaces.
We don’t do it just because code repeats.
If the shared shape is real, we name it and centralize it. If it’s still mostly resemblance, we leave it inline and let the system settle before pretending it’s earned a boundary.
More in this domain: Infrastructure
Browse allHow we decide between Cloud SQL connectors, Auth Proxy, and private IP
Cloud SQL connectors, the Auth Proxy, and private IP are not interchangeable secure connection options. They change identity, routing, deployment shape, and how much network plumbing the team actually owns.
IAM DB auth for Cloud SQL: when it simplifies security and when it complicates delivery
IAM DB auth can reduce password sprawl and make revocation cleaner, but it also turns database access into an identity operating model that depends on disciplined service-account boundaries.
Safe scaling defaults for Cloud Run + Postgres
Cloud Run autoscaling is not a database strategy. Safe defaults keep the application from scaling itself into a Postgres incident before the team understands the workload.
Cloud Run request timeouts don't kill your code (so your architecture has to)
A Cloud Run request timeout ends the request, not necessarily the work. If the operation can outlive its caller, the system needs explicit job semantics instead of hope.
Cloud Run scaling from zero is a feature until it isn't
Scale to zero is a good default for request-driven services, until startup delay, warm-capacity needs, or instance caps turn it into user-visible reliability behavior instead of a pricing feature.
Related patterns
How we decide between directory per environment and shared stacks in Pulumi
We do not force DRY across environments by default. We keep Pulumi environments separate until shared code, shared rules, and drift risk make consolidation cheaper than duplication.
Why we usually choose Pulumi over Terraform
Pulumi is our default when infrastructure starts behaving like software. Existing Terraform estates can still be the better decision when the migration cost is higher than the operational gain.
How we structure a directory per environment in Pulumi
When we keep Pulumi environments separate, we make the environment boundary obvious in the filesystem and keep shared logic outside it.
What goes in Pulumi stack config and what doesn't
We use Pulumi stack config for environment-specific values, not as a hiding place for infrastructure logic.