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 allCloud Tasks vs. Pub/Sub for Cloud Run: routing, retries, and rate limits
Use Cloud Tasks for a specific operation that needs scheduling or controlled dispatch. Use Pub/Sub when independent consumers need the same event. Either HTTP handler must tolerate retries.
Cloud Run scale to zero: cold starts and minimum instances
Keep scale to zero when the first request can wait. Pay for minimum instances when measured startup latency hurts the service, and decide background CPU separately.
Direct VPC egress vs. Serverless VPC Access for Cloud Run
Use Direct VPC egress for most new Cloud Run connections to a VPC. Check address capacity, startup behavior, and outbound routing before retiring a working connector.
Internal-only Cloud Run: ingress, identity, and the caller’s network path
An internal Cloud Run service needs an allowed request path and an authorized caller. The correct setup differs for Cloud Tasks, another Cloud Run service, and human access.
Pulumi stack config: values, code, and secrets
Stack config supplies values within an infrastructure design. Keep topology and operating policy explicit in code, validate the inputs, and manage secrets according to who owns their lifecycle.
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.
How we treat Terraform state in team environments
Terraform starts feeling fragile in teams when state is treated like a backend setting instead of a shared dependency for safe change.