About Aspects
- The specification of Aspects was changed in AWS CDK v2.172.0 released on December 7, 2024
- Two features were introduced: "priority" and "Stabilization loop"
- Stabilization loop is enabled when the feature flag is true (priority works regardless of the feature flag)
- However, unlike normal feature flags, this flag automatically becomes true when upgrading existing projects to this CDK version
- Two concepts were introduced to Aspects: "Mutating" and "Readonly"
What are Aspects?
Aspects are a way to apply operations to all configurations within a specific scope, such as a stack or construct.
For example, they can be used for validation tasks like "checking if versioning is enabled for all S3 buckets in a stack."
Note: In this article, "Aspects" is used to refer to the concept and functionality itself, while "Aspect" refers to the implementation/class.
import { App, Aspects, IAspect, Stack, Tokenization } from 'aws-cdk-lib';
import { CfnBucket } from 'aws-cdk-lib/aws-s3';
import { IConstruct } from 'constructs';
// Aspect is implemented by implementing the IAspect interface
export class BucketVersioningChecker implements IAspect {
// Write validation logic in the visit method
public visit(node: IConstruct) {
// Since the visit method is applied to all resources within the Construct passed by Aspect, add a condition to execute only for CfnBucket resources
if (node instanceof CfnBucket) {
// Throw an error if bucket versioning is disabled
if (
!node.versioningConfiguration ||
(!Tokenization.isResolvable(node.versioningConfiguration) && node.versioningConfiguration.status !== 'Enabled')
) {
throw new Error('Versioning is not enabled');
}
}
}
}
const app = new App();
const stack = new Stack(app, 'MyStack');
// Apply BucketVersioningChecker to all resources in MyStack
Aspects.of(stack).add(new BucketVersioningChecker());
CDK Lifecycle
AWS CDK has four phases in its lifecycle:
- Construct phase
- Prepare phase
- Validate phase
- Synthesize phase
Most of the CDK code written by users (such as resource instance creation) is executed in the Construct phase.
Aspects are executed in the Prepare phase after the Construct phase is completed, and the visit method of the Aspect class, i.e., the Aspect processing, runs.
Therefore, it's important to note that Aspect processing is not executed when the Aspect instance is created (It is executed after all resource instance creation that occurs after Aspect instance creation is completed).
Background of Specification Changes (Problems)
Before the current Aspects specification changes, there were two problematic cases:
Case 1: Creating New Resources in Upper Nodes within an Aspect
The first issue occurred when there was both an "Aspect that adds resources to upper nodes" and an "Aspect that traverses the entire tree of resources". The problem was that resources added by the former Aspect were not traversed by the latter Aspect that scans the entire tree.
Specifically, this occurred in cases like:
- Applying an Aspect that adds new resources to a stack to a Construct within that stack
- Applying another Aspect that traverses all resources to the stack
This was because the previous implementation of Aspects executed Aspects from top to bottom in the construct tree, starting with Aspects applied to the highest node.
*Relevant code from the CDK at that time: CDK Source Code
In other words, since the latter Aspect was applied to the stack (a higher-level node), it was executed first and traversed all resources. Then, when the former Aspect added new resources afterward, these new resources were not included in the traversal.
This issue was also related to the fact that even when an Aspect adds new nodes (resources) to the existing construct tree, other Aspects traverse the construct tree as it was before these new nodes were added.
Here's a code example (*see Issue #21341):
import { Aspects, Tags, IAspect, Stack, StackProps } from 'aws-cdk-lib';
import { Bucket } from 'aws-cdk-lib/aws-s3';
import { Construct, IConstruct } from 'constructs';
export class CdkIssueStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// Traverse the entire stack and add tags (Tag-adding Aspect applied to stack)
// This executes first (because the add target is the stack, which is the topmost node)
Tags.of(this).add('test-tag', 'test-value');
new MyConstruct(this, 'MyConstruct');
}
}
class MyConstruct extends Construct {
constructor(scope: IConstruct, id: string) {
super(scope, id);
const stack = Stack.of(scope);
// Create directly under the stack
new Bucket(stack, 'BucketWithTags');
// Apply an Aspect that adds resources to the upper node (stack) to this Construct
// This executes second (because the add target is a Construct, which is lower than the stack)
Aspects.of(this).add(new MyDynAspect());
}
}
class MyDynAspect implements IAspect {
private processed = false;
public visit(node: IConstruct): void {
// Condition to call only once
if (!this.processed) {
// Create a Bucket directly under the stack (upper node)
const stack = Stack.of(node);
new Bucket(stack, 'BucketWithoutTagsThatShouldHave');
this.processed = true;
}
}
}
This executes according to the lifecycle explained above. Specifically, BucketWithTags is generated in the Construct phase, and tag application and BucketWithoutTagsThatShouldHave generation are executed in the Prepare phase. This is because Tags internally uses Aspects, and Aspects are executed in the Prepare phase.
The synthesized tree from this code looks like this:
- Stack
- BucketWithTags: Has tags
- BucketWithoutTagsThatShouldHave: No tags
However, BucketWithoutTagsThatShouldHave should also have tags applied, which was raised as an issue.
Case 2: Multiple Aspects Applied to the Same Construct
While Case 1 was related to the traversal order of nodes in the construct tree (and the fact that resources added by an Aspect are not traversed by other Aspects), Case 2 was related to the order of Aspect execution. Specifically, this was an issue with the execution order when multiple Aspects are applied to the same construct.
Consider a case with the following Aspect configuration:
- ValidateEncryptionAspect
- Validates that encryption is enabled for all S3 buckets
- DefaultEncryptionAspect
- Sets default encryption for all S3 buckets
- EnvironmentBasedEncryptionAspect
- Changes S3 bucket encryption based on environment variables
In this case, the following problems could occur:
- ValidateEncryptionAspect executes first but fails because DefaultEncryptionAspect hasn't been applied yet
- DefaultEncryptionAspect is overwritten by EnvironmentBasedEncryptionAspect
- ValidateEncryptionAspect cannot verify the final encryption configuration set by EnvironmentBasedEncryptionAspect
Previously, Aspects were executed in the order they were applied, but having more flexible control elements would be beneficial. For example, implementing a case where ValidateEncryptionAspect is added first but needs to be executed last.
New Specification
In v2.172.0 released on December 7, 2024, the Aspects specification was changed to solve the above issues.
There are two changes to the Aspects specification:
Specification 1: Introduction of Priority
The "priority" feature was introduced, ensuring that across the entire Construct tree, Aspects with higher priority (smaller priority numbers) are guaranteed to be applied before Aspects with lower priority (larger priority numbers).
This specification was introduced primarily to solve Case 2 mentioned above, allowing specification of the order in which Aspects are applied. (*Note that the order of issues and specifications is reversed)
Along with this "priority" feature, two concepts were introduced to Aspects to ensure consistency across various Construct libraries: "Mutating" and "Readonly".
Here, "Mutating" refers to adding new nodes (resources) to the construct tree or modifying existing nodes, while "Readonly" refers to operations that don't modify the tree, such as inspections (validations).
As an implementation of these two concepts, default priority values and priority values for mutating and readonly Aspects are defined as static properties of the AspectPriority
class (called an Enum-Like Class in CDK).
The specific values are defined as follows, with execution order being "Mutating" → "Default" → "Readonly", and Aspects without specified priority being executed after mutating Aspects but before readonly Aspects.
/**
* Default Priority values for Aspects.
*/
export class AspectPriority {
/**
* Suggested priority for Aspects that mutate the construct tree.
*/
static readonly MUTATING: number = 200;
/**
* Suggested priority for Aspects that only read the construct tree.
*/
static readonly READONLY: number = 1000;
/**
* Default priority for Aspects that are applied without a priority.
*/
static readonly DEFAULT: number = 500;
}
Furthermore, this allows control of Aspect application order not just for "multiple Aspects applied to the same construct" as in Case 2, but across the entire Construct tree.
For example, under the previous specification, if you applied a "resource inspection Aspect (readonly)" to a higher-level stack and a "resource modification Aspect (mutating)" to a lower-level construct, the resource inspection Aspect at the higher level would run first because it was applied to a higher node in the construct tree.
However, with the newly introduced priority system, by setting the priority of the resource inspection Aspect lower than the resource modification Aspect (READONLY
has lower priority/higher numerical value than MUTATING
), you can make the readonly resource inspection Aspect applied to the higher node run last.
import { Aspects, IAspect, Stack, StackProps, Tokenization, AspectPriority } from 'aws-cdk-lib';
import { Bucket, CfnBucket } from 'aws-cdk-lib/aws-s3';
import { Construct, IConstruct } from 'constructs';
export class CdkIssueStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
new MyConstruct(this, 'MyConstruct');
Aspects.of(this).add(new VersioningCheckAspect(), { priority: AspectPriority.READONLY });
}
}
class MyConstruct extends Construct {
constructor(scope: IConstruct, id: string) {
super(scope, id);
new Bucket(this, 'BucketWithTags');
Aspects.of(this).add(new VersioningAspect(), { priority: AspectPriority.MUTATING });
}
}
class VersioningCheckAspect implements IAspect {
public visit(node: IConstruct) {
if (node instanceof CfnBucket) {
if (
!node.versioningConfiguration ||
(!Tokenization.isResolvable(node.versioningConfiguration) && node.versioningConfiguration.status !== 'Enabled')
) {
throw new Error('Versioning is not enabled');
}
}
}
}
class VersioningAspect implements IAspect {
public visit(node: IConstruct): void {
if (node instanceof CfnBucket) {
if (
!node.versioningConfiguration ||
(!Tokenization.isResolvable(node.versioningConfiguration) && node.versioningConfiguration.status !== 'Enabled')
) {
node.versioningConfiguration = {
status: 'Enabled',
};
}
}
}
}
Specification 2: Introduction of Stabilization Loop
The Stabilization loop is a mechanism to solve Case 1 mentioned above, which executes the application to the construct tree multiple times when calling Aspects.
It has the following main characteristics:
- Even when an Aspect applied to a construct creates new resources in higher-level nodes, these additional resources can be inspected by Aspects applied to higher-level nodes (like stacks) that execute first
- Can execute Aspects created by other Aspects (nested Aspect execution)
The first point specifically addresses Case 1.
Regarding the second point, nested Aspect execution was actually not possible before.
Here's an example of nested Aspect execution:
class MyConstruct extends Construct {
constructor(scope: IConstruct, id: string) {
super(scope, id);
Aspects.of(this).add(new ParentAspect());
}
}
class ParentAspect implements IAspect {
public visit(node: IConstruct): void {
Aspects.of(node).add(new MyDynAspect());
}
}
class MyDynAspect implements IAspect {
private processed = false;
public visit(node: IConstruct): void {
if (!this.processed) {
const stack = Stack.of(node);
new Bucket(stack, 'BucketWithoutTagsThatShouldHave');
this.processed = true;
}
}
}
While we explained that the Stabilization loop executes application to the construct tree multiple times, don't worry - the same Aspect is only applied (executed) once to the same resource.
The execution order of Aspects can be understood from the following image (provided in the RFC: Aspects Priority Ordering):
Additional Notes
Retrieving Aspect Information and Overriding Priority
After applying an Aspect, you can retrieve a list of Aspects applied to that scope and their information using Aspects.of(scope).applied
.
These are instances of the AspectApplication
class, which has three properties: aspect
, construct
, and priority
.
Through these instances, you can also override priority values after declaring an Aspect. It's recommended to use these three properties appropriately for comparisons and other purposes when overriding priorities.
const app = new App();
const stack = new MyStack(app, 'MyStack');
Aspects.of(stack).add(new MyAspect());
let aspectApplications: AspectApplication[] = Aspects.of(stack).applied;
for (const aspectApplication of aspectApplications) {
// The aspect we are applying
console.log(aspectApplication.aspect);
// The construct we are applying the aspect to
console.log(aspectApplication.construct);
// The priority it was applied with
console.log(aspectApplication.priority);
// Change the priority
aspectApplication.priority = 700;
}
Feature Flags
Of the two specification changes mentioned above, the second one, "Introduction of Stabilization Loop," is applied when the feature flag is true. (*The first "priority" feature is applied regardless of the feature flag.)
*For feature flags, please refer to the official documentation.
Normally, feature flags are true by default for new CDK projects (when using cdk init
with CDK versions after the feature flag was introduced), but for existing CDK projects, they are treated as false unless explicitly set to true, so you need to specify them yourself to enable the feature.
However, the feature flag for this Stabilization loop is different from normal feature flags in that it "automatically" becomes true when upgrading to the relevant CDK version, even for existing projects, so care must be taken.
*Source is at the following link: Feature Flag Source
This means that the Stabilization loop will automatically be enabled when upgrading the CDK version for both new and existing projects, and you need to explicitly set the feature flag to false to switch back to the traditional single traversal of the construct tree.
If you want to upgrade an existing CDK project to v2.172.0 or higher and want to "disable" the "Introduction of Stabilization Loop" feature flag (if you want to enable it, you don't need to do anything other than upgrading the CDK version), do the following:
- Add
"@aws-cdk/core:aspectStabilization": false
to thecontext
incdk.json
{
// ...
// ...
"context": {
// ...
// ...
"@aws-cdk/core:aspectStabilization": false
}
}
Top comments (0)