The AWS Construct Library is a rich class library of CDK constructs which represent all resources offered by the AWS Cloud.
The purpose of this document is to describe common guidelines for designing the APIs in the AWS Construct Library in order to ensure a consistent and integrated experience across the entire AWS surface area.
As much as possible, the guidelines in this document are enforced using the awslint tool which reflects on the APIs and verifies that the APIs adhere to the guidelines.
When a guideline is backed by a linter rule, the rule name will be referenced
like this: awslint|resource-class-is-construct
and anchored with the rule name.
For the purpose of this document we will use "Foo" to denote the official
name of the resource as defined in the AWS CloudFormation resource specification
(i.e. "Bucket", "Queue", "Topic", etc). This notation allows deriving names from
the official name. For example, FooProps
would be BucketProps
, TopicProps
,
etc, IFoo
would be IBucket
, ITopic
and so forth.
The guidelines in this document use TypeScript (and npm package names) since this is the source programming language used to author the library, which is later packaged and published to all programming languages through jsii.
AWS resources are organized into modules based on their AWS service. For example, the "Bucket" resource, which is offered by the Amazon S3 service will be available under the @aws-cdk/aws-s3 module.
Constructs are the basic building block of CDK applications. They represent abstract cloud resources of any complexity.
Construct initializer (constructor) signature should always be:
constructor(scope: cdk.Construct, id: string, props: FooProps)
If all initialization properties are optional, the props
argument must also be
optional.
NOTE: This rule breaks down for the case where there are two (or more) potential arguments, and exactly one (or at least one) of them is required. Then the props will be marked as optional, but the whole object is not optional since there is no valid use of not specifying anything.
Ideally we rather not design APIs such as this, but there might be cases where this is the best approach. In those cases, it is okay to "exclude" this role:scope in
package.json
.
constructor(scope: cdk.Construct, id: string, props: FooProps = { })
Each module in the AWS Construct Library includes generated constructs which represent the "raw" CloudFormation resources.
- These classes are named
CfnFoo
- They extend
cdk.Resource
(which extendscdk.Construct
) - Their constructor accepts a
props
parameter of typeCfnFooProps
CfnFooProps
represents the exact set of properties as defined in the AWS CloudFormation resource specification.- They have readonly properties which represent all the runtime attributes of the resource.
NOTE: there are no linting rules against this section because this layer is entirely generated by the cfn2ts tool according to these guidelines so there is no need to lint against it.
Every AWS resource should have a resource interface IFoo
.
This interface represents both resources defined within the same stack (aka "internal" or "owned") or resources that are defined in different stack/app (aka "imported", "existing", "external" or "unowned"). Throughout this document we shall refer to these two types of resources as "internal" and "external".
Resource interfaces should extend cdk.IConstruct
in order to allow consumers
to take advantage of construct capabilities such as unique IDs, paths, scopes, etc.
When resources are referenced anywhere in the API (e.g. in properties or methods
of other resources or higher level constructs), the resource interface (IFoo
)
should be preferred over the concrete resource class (Foo
). This will allow
users to supply either internal or external resources.
Every AWS resource has a set of "physical" runtime attributes such as ARN, physical names, URLs, etc. These attributes are commonly late-bound, which means they can only be resolved when AWS CloudFormation actually provisions the resource.
Resource attributes almost always represent string values (URL, ARN, name). Sometimes they might also represent a list of strings.
awslint: resource-attribute
awslint: resource-attribute-immutable
All resource attributes must be represented as readonly properties of the resource interface. The names of the attributes must correspond to the CloudFormation resource attribute name.
Since attribute values can either be late-bound ("a promise to a string") or concrete ("a string"), the AWS CDK has a mechanism called "tokens" which allows codifying late-bound values into strings or string arrays. This approach was chosen in order to dramatically simplify the type-system and ergonomics of CDK code. As long as users treat these attributes as opaque values (e.g. not try to parse them or manipulate them), they can be used interchangeably.
As long as attribute values are not manipulated, they can still be concatenated idiomatically. For example:
`This is my bucket name: ${bucket.bucketName} and bucket ARN: ${bucket.bucketArn}`
Even though bucketName
and bucketArn
will only be resolved during
deployment, the CDK will identify those as tokens and will convert this string
into an { "Fn::Join" }
expression which includes the relevant intrinsic
functions.
If needed, you can query whether an object includes unresolved tokens by using
the cdk.isToken(x)
function.
Resource attributes should use a type that corresponds to the resolved AWS
CloudFormation type (e.g. string
, string[]
).
At the moment, attributes that represent strings, are represented as string
in
the CfnFoo
resource. However, other types of tokens (string arrays, numbers)
are still represented as Token
. You can use token.toList()
to represent a token as a
string array, and soon we will also have toNumber()
.
Each CfnFoo
resource must have a corresponding Foo
high-level (L2)
class.
Classes which represent AWS resources are constructs (they must extend the cdk.Construct
class
directly or indirectly).
Resource constructs are initialized with a set of properties defined in an interface FooProps
.
Initialization properties should enable developers to define the resource in their application. Generally, they should expose most of the surface area of the resource.
Initialization properties should be required only if there is no sane default that can be provided or calculated. By providing sensible and safe defaults (or "smart defaults"), developers can get started quickly.
In order to allow users to work with resources that are either internal or external to their stack, AWS resources should provide an "import/export" mechanism as described in this section.
Every AWS resource class must include a static method called import
with the
following signature:
static import(scope: cdk.Construct, id: string, props: XxxImportProps): IXxx
This method returns an object that implements the resource interface (IXxx
)
and represents an "imported resource".
The "props" argument is XxxImportProps
, which is an interface that declares
properties that allow the user to specify an external resource identity, usually
by providing one or more resource attributes such as ARN, physical name, etc.
The import interface should have the minimum required properties, that is: if it
is possible to parse the resource name from the ARN (using cdk.Stack.parseArn
),
then only the ARN should be required. In cases where it
is not possible to parse the ARN (e.g. if it is a token and the resource name
might have use "/" characters), both the ARN and the name should be optional and
runtime-checks should be performed to require that at least one will be defined.
See ecr.RepositoryAttributes
for an example.
The recommended way to implement the import
method is as follows:
- A public abstract base class called
XxxBase
which implementsIXxx
and extendscdk.Construct
. - The base class should provide as much of the implementation of
IXxx
as possible given the context it has. In most cases,grant
methods,metric
methods, etc. can be implemented at at that level. - A private class called
ImportedXxx
which extendsXxxBase
and implements any remaining abstract members. - The
import
static method should be have the following implementation:
public static import(scope: cdk.Construct, id: string, props: XxxImportProps): IXxx {
return new ImportedXxx(scope, id, props);
}
All resource interfaces (IXxx
) must declare a method called export
with the
following signature:
export(): XxxImportProps
This method can be used to export this resource from the current stack and use
it in another stack through an Xxx.import
call.
The implementation of export
is different between internal resources (Xxx
) and
external imported resource (ImportedXxx
as recommended above):
For internal resources, the export
method should produce a CloudFormation Output
for each resource attribute, and return a set of { "Fn::ImportValue" }
tokens
so they can be imported to another stack.
class Xxx extends XxxBase {
public export(): XxxImportProps {
return {
attr1: new cdk.CfnOutput(this, 'Attr1', { value: this.attr1 }).makeImportValue().toString(),
attr2: new cdk.CfnOutput(this, 'Attr2', { value: this.attr2 }).makeImportValue().toString(),
}
}
}
For external resources, we know the actual values, so basically you would want to reflect
your props
as is:
class ImportedXxx extends XxxBase {
constructor(scope: cdk.Construct, id: string, private readonly props: XxxImportProps) {
// ...
}
public export() {
return this.props;
}
}
The reason we are defining export
on the resource interface and not on the resource
class is in order to allow "composite export" scenarios, where a higher-level construct
wants to implement export
by composing the exports of multiple resources:
interface MyCompositeProps {
bucket: s3.IBucket;
topic: sns.ITopic;
}
class MyComposite extends cdk.Construct {
public export(): MyCompositeImportProps {
return {
bucket: this.bucket.export(),
topic: this.topic.export()
}
}
}
In this scenario, you don't know if bucket
or topic
are internal or external resources,
but you still want export to work.
The @default
documentation tag must be included on all optional properties of
interfaces.
Here's a complete "template" for the types required when defining a resource in the AWS construct library:
// must extend cdk.IConstruct
// extends all interfaces that are applicable for both internal
// and external resources of this type
export interface IFoo extends cdk.IConstruct, ISomething {
// attributes
readonly fooArn: string;
readonly fooBoo: string;
// security group connections (if applicable)
readonly connections: ec2.Connections;
// permission grants (adds statements to the principal's policy)
grant(grantee?: iam.IGrantable, ...actions: string[]): void;
grantFoo(grantee?: iam.IGrantable): void;
grantBar(grantee?: iam.IGrantable): void;
// resource policy (if applicable)
addToResourcePolicy(statement: iam.PolicyStatement): void;
// role (if applicable)
addToRolePolicy(statement: iam.PolicyStatement): void;
// pipeline (if applicable)
addToPipeline(stage: pipelineapi.IStage, name: string, props?: FooActionProps): FooAction;
// metrics
metric(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric;
metricFoo(props?: cloudwatch.MetricOptions): cloudwatch.Metric;
metricBar(props?: cloudwatch.MetricOptions): cloudwatch.Metric;
// export
export(): FooImportProps;
// any other methods/properties that are applicable for both internal
// and external resources of this type.
// ...
}
// base class to share implementation between internal/external resources
// it has to be public sadly.
export abstract class FooBase extends cdk.Construct implements IFoo {
// attributes are usually still abstract at this level
public abstract readonly fooArn: string;
public abstract readonly fooBoo: string[];
// the "export" method is also still abstract
public abstract export(): FooAttributes;
// grants can usually be shared
public grantYyy(grantee?: iam.IGrantable) {
// ...
}
// metrics can usually be shared
public metricFoo(...) { ... }
}
// extends the abstract base class and implement any interfaces that are not applicable
// for imported resources. This is quite rare usually, but can happen.
export class Foo extends FooBase implements IAnotherInterface {
// the import method is always going to look like this.
public static import(scope: cdk.Construct, id: string, props: FooImportProps): IFoo {
return new ImportedFoo(scope, id, props);
}
// implement resource attributes as readonly properties
public readonly fooArn: string;
public readonly fooBoo: string[];
// ctor's 3rd argument is always FooProps. It should be optional (`= { }`) in case
// there are no required properties.
constructor(scope: cdk.Construct, id: string, props: FooProps) {
super(scope, id);
// you would usually add a `CfnFoo` resource at this point.
const resource = new CfnFoo(this, 'Resource', {
// ...
});
// proxy resource properties
this.fooArn = resource.fooArn;
this.fooBoo = resource.fooBoo;
}
// this is how export() should be implemented on internal resources
// they would produce a stack export and return the "Fn::ImportValue" token
// for them so they can be imported to another stack.
public export(): FooAttributes {
return {
fooArn: new cdk.CfnOutput(this, 'Arn', { value: this.fooArn }).makeImportValue().toString(), // represent Fn::ImportValue as a string
fooBoo: new cdk.CfnOutput(this, 'Boo', { value: this.fooBoo }).makeImportValue().toList() // represent as string[]
// ...
}
}
}
// an internal class (don't export it) representing the external (imported) resource
class ImportedFoo extends FooBase {
public readonly string fooArn;
public readonly string[] fooBoo;
constructor(scope: cdk.Construct, id: string, private readonly props: FooImportProps) {
super(scope, id);
this.fooArn = props.fooArn;
this.fooBoo = props.fooBoo;
}
public export() {
return this.props; // just reflect props back
}
}
- IAM (
role
,addToRolePolicy
,addToResourcePolicy
) - Grants (
grantXxx
) - Metrics (
metricXxx
) - Events (
onXxx
) - Security Groups (
connections
) - Pipeline Actions (
addToPipline
) - SNS Targets
-
_asFooTarget
- TODO: other cross AWS patterns