SecureBucket class. Let’s learn how to create custom ComponentResources to encapsulate complexity.1. The Problem with Raw Resources#
In Episode 3, we created an AWS VPC and Subnet directly in index.ts.
If another team in your company needs a VPC, they would have to copy and paste your code. If the security team later mandates that all VPCs must have a Flow Log attached, you now have to track down every team that copied your code and force them to update it.
This violates the DRY (Don’t Repeat Yourself) principle.
In Terraform, you solve this by writing a Module. In Pulumi, you solve this by writing a ComponentResource.
2. Authoring a ComponentResource#
A ComponentResource is simply a standard TypeScript class that extends pulumi.ComponentResource. It acts as a logical container that groups multiple raw AWS resources together in the state file.
Let’s build a SecureBucket component that automatically enforces enterprise security standards.
Create a new file named secureBucket.ts:
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
// 1. Define the strongly-typed arguments for your component
export interface SecureBucketArgs {
bucketName: string;
// We can make properties optional!
enableVersioning?: boolean;
}
// 2. Extend the base ComponentResource class
export class SecureBucket extends pulumi.ComponentResource {
// We expose the raw Bucket object so consumers can access its ARN/URL later
public readonly bucket: aws.s3.Bucket;
constructor(name: string, args: SecureBucketArgs, opts?: pulumi.ComponentResourceOptions) {
// The first argument is a unique Type identifier for your component
super("custom:x:SecureBucket", name, {}, opts);
// 3. Create the raw AWS Resources, ensuring we pass `parent: this`
this.bucket = new aws.s3.Bucket(args.bucketName, {
bucket: args.bucketName,
// Automatically enforce security requirements!
acl: "private",
versioning: {
enabled: args.enableVersioning ?? true,
}
}, { parent: this }); // <--- CRITICAL
// Block all public access unconditionally
new aws.s3.BucketPublicAccessBlock(`${args.bucketName}-pab`, {
bucket: this.bucket.id,
blockPublicAcls: true,
blockPublicPolicy: true,
ignorePublicAcls: true,
restrictPublicBuckets: true,
}, { parent: this }); // <--- CRITICAL
// 4. Register outputs to finalize the component
this.registerOutputs({
bucketName: this.bucket.bucket,
bucketArn: this.bucket.arn,
});
}
}The Importance of { parent: this }#
Notice how every raw AWS resource inside the class is passed { parent: this } in its Options object.
If you forget this, the resources will be created successfully, but in the Pulumi State file, they will appear floating aimlessly at the root level. By passing { parent: this }, you instruct the Pulumi Engine to nest the Bucket and the AccessBlock underneath your SecureBucket logical component in the dependency tree. This makes pulumi destroy and stack visualization much cleaner.
3. Consuming the Component#
Now that we have abstracted the complexity into secureBucket.ts, our main index.ts file becomes incredibly clean.
Open index.ts:
import { SecureBucket } from "./secureBucket";
// Any developer in the company can now provision a fully compliant
// bucket in a single line of code.
const appData = new SecureBucket("app-data", {
bucketName: "my-company-app-data-prod",
});
const logs = new SecureBucket("access-logs", {
bucketName: "my-company-access-logs-prod",
enableVersioning: false, // Override the default
});When you run pulumi up, the CLI output will look like this:
Type Name Plan
+ pulumi:pulumi:Stack my-project-dev create
+ ├─ custom:x:SecureBucket app-data create
+ │ ├─ aws:s3:Bucket app-data-prod create
+ │ └─ aws:s3:PublicAccessBlock app-data-pab create
+ └─ custom:x:SecureBucket access-logs create
+ ├─ aws:s3:Bucket logs-prod create
+ └─ aws:s3:PublicAccessBlock logs-pab create Notice how the tree structure proves that the raw AWS resources are properly nested under our custom component!
4. Multi-Layer Abstractions#
Because ComponentResource is just a TypeScript class, you can nest them infinitely.
For example, you could author a Microservice component that provisions:
- An AWS ECS Fargate Cluster.
- An Application Load Balancer.
- A Route53 DNS Record.
- And a
SecureBucket(our component from above!) for file storage.
You then publish this Microservice class to your company’s internal NPM registry. Application developers can npm install @my-company/infra and deploy a production-grade microservice with 10 lines of code, completely blind to the 500 lines of AWS API calls happening under the hood.
This is the holy grail of Platform Engineering.
Troubleshooting & Common Errors#
TypeError: Cannot read properties of undefined (reading 'registerOutputs')- Root Cause: You forgot to call
super(...)inside theconstructorof your class. TypeScript requires you to invoke the parent class constructor before usingthis. - Solution: Ensure
super("my:module:Type", name, {}, opts)is the very first line inside the constructor.
- Root Cause: You forgot to call
Resources are not visually grouped together in
pulumi up- Root Cause: You forgot to pass
{ parent: this }as the 3rd argument to the AWS resources inside your component. - Solution: Add the ResourceOptions object to all child resources.
- Root Cause: You forgot to pass
Conclusion & Next Steps#
You have successfully replaced Terraform Modules with strictly-typed, Object-Oriented Classes. By authoring ComponentResources, you can enforce security boundaries and dramatically simplify the developer experience for your team.
However, even with reusable components, you rarely want to deploy your Database, your VPC, and your Frontend React App in the exact same index.ts file. A single typo in the frontend code shouldn’t risk tearing down the production database.
We need to split our architecture into smaller, isolated Stacks.
In Episode 8: Cross-Stack References, we will learn how to break a monolithic Pulumi project into micro-stacks, and how to safely pass data (like a VPC ID) between them.

