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.
Repeated Pulumi code earns abstraction when it represents one stable operational responsibility with invariants worth enforcing.
The number of copies is only a signal. Two identical blocks may diverge next week. Six similar blocks may already represent one platform product. Extracting by line count guesses at the boundary instead of finding it.
Name the responsibility first
Before creating a function or component, finish this sentence:
This abstraction creates and owns a standard __.
“Cloud Run service” may still be too broad. “Private internal HTTP service with a dedicated identity, bounded scaling, database access, and standard alerts” names a useful platform responsibility.
A good abstraction centralizes decisions that should remain invariant:
- resource parentage and naming
- identity and baseline IAM
- required logging and monitoring
- network attachment
- safe scaling or retention defaults
- deletion and recovery policy
- outputs consumers are allowed to depend on
Callers provide the values and deliberate variations. They don’t rebuild the invariant from options every time.
Wait until similarity survives real use
Keep the first few implementations inline while the workload exposes differences. Compare them after deployments and incidents, not immediately after copying.
Ask which differences are accidental and which reflect another responsibility. Two services may use the same provider resource but have different availability, ingress, database, and execution models. They should share a low-level helper at most, not one “standard service” component with fourteen switches.
Early duplication gives the concept room to become clear. Premature abstraction gives the first guess an API and then charges every future caller for disagreeing with it.
Judge the call site
The abstraction should make consequential behavior easier to see.
createInternalApi('orders', {
image,
database,
maxInstances: 8,
allowedCallers: [schedulerServiceAccount],
});This can be useful when createInternalApi has a documented, stable contract. It isn’t useful when it silently chooses region, ingress, timeout, service account, secret access, VPC, database pool settings, and deletion behavior from stack-name conventions.
A reviewer should understand the important shape from the name, arguments, and nearby documentation. They may open the implementation to inspect mechanics, but they shouldn’t need to discover surprises.
Option growth is boundary feedback
Watch the interface after extraction.
Warning signs include:
- several Boolean flags
- mutually dependent options
- caller-specific escape hatches
- generic maps passed directly to provider resources
- defaults disabled by most consumers
- branches based on resource name or environment
- outputs exposing internal children so callers can patch them
One optional feature doesn’t condemn a component. Repeated exceptions show that the abstraction groups workloads that only look alike.
Split the component by responsibility or inline the outlier. Don’t preserve a bad API because deleting abstraction feels like regression. The sunk cost will cope.
Use ComponentResources for owned resource graphs
A Pulumi component is appropriate when the abstraction owns several related resources, needs a stable parent, exposes intentional outputs, and should appear as one conceptual unit in previews.
Use a plain function for small stateless composition where resource parentage and lifecycle need no separate identity. Use a utility for pure naming or configuration logic.
The mechanism should reflect ownership. Turning every helper into a component creates ceremony. Returning a bag of unrelated resources from one function hides lifecycle.
Preserve resource identity during extraction
Moving existing resources under a component can change logical names and URNs. Use aliases and staged refactors so abstraction doesn’t recreate production infrastructure.
First extract without changing provider inputs. Preview every stack. Expect no replacements. Only then change behavior through a separate review.
An abstraction refactor and an infrastructure migration are different changes even when Pulumi lets them fit in one commit.
Test policy and preview behavior
Unit tests can verify pure input logic and mocks can inspect registered resources. More importantly, run previews against representative stacks and assert critical policy through policy-as-code or repository tests.
Test defaults and escape hatches. A component that promises private ingress, bounded scale, or protected storage should make violations difficult and visible.
Avoid snapshot tests of every generated property when they make provider upgrades miserable. Test the contract the abstraction exists to enforce.
Know when duplication is cheaper
Leave code inline when the pattern is small, still moving, used only twice, or clearer with local detail. Duplicate an explicit ten-line IAM binding before creating a generic permissions framework with callbacks and policy maps.
Abstraction has recurring costs: API design, documentation, versioning, migration, debugging, and coordination across callers. It must remove more of those costs than it creates.
When to abstract
Abstract after the repeated code reveals one named responsibility, stable invariants, and a call site that improves review. Keep differences explicit and split outliers rather than feeding the interface forever.
Repeated code is visible. A bad abstraction hides its cost. Keep the version the team can understand until the shared boundary has earned a name.
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.
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.
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.
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.