This topic has been in my backlog to write about for a long time. I’ve had to sit with it for so long since I was “missing a piece of the puzzle.” But after attending Re:Invent this year I feel like I’ve got enough to share my thoughts. The topic is also surprisingly nuanced, so I figured laying some groundwork by describing the most common patterns would be a place to start.
props
objectThis is most commonly used as it’s one of the most natural dependency mechanisms in JavaScript. For a given stack or construct, it declares its need for something external. Then at runtime, you have to satisfy that need during the call to the constructor. You define this dependency by expanding the props
object which inherits from the StackProps
CDK class. There are two variations here.
The first is simpler declaring a dependency for a specific L2 or L3 construct. This is where most applications start, and if you’re building a construct library as far as you need to go. However in the long run I’ve found this method to be taxing on the synth command, leading to circular dependency issues that can obstruct the project’s ability to build successfully.
// myConstruct.ts export type MyConstructProps = { someInstance: ec2.Instance; } export default class MyConstruct extends Construct { constructor(scope: Construct, id: string, props: MyConstructProps) { const { someInstance } = props; const securityGroup = ec2.SecurityGroup.fromSecurityGroupId(this,' SG', 'sg-12345', {...}); someInstance.addSecurityGroup(securityGroup); } }
// somewhereElse.ts import MyConstruct from './myConstruct'; const someInstance = new ec2.Instance(this, 'targetInstance', {...}); const myConstruct = new MyConstruct(this, 'mine', { someInstance })
The second variation of this dependency style involves building explicit dependency paths between the stacks that deploy resources. This does remove some of the “generic” functionality, meaning that we can’t receive any old EC2 instance anymore, instead, we have to receive one from somewhere specific within the application. Additionally, this will rely on there being at least one stack that orchestrates between the dependent and provider stacks.
// orchestratorStack.ts import ProviderStack from './providerStack' import DependentStack from './dependentStack' export default class Orchestrator extends Stack { constructor(scope: Construct, id: string, props: StackProps) { const provider = new ProviderStack(this, 'provider'); const dependent = new DependentStack(this, 'dependent', {provider}); // This next line is _vitally_ important. As it informs the build system that the provider stack has to be fully built prior to building the dependent. Omitting this line will lead to them being built in parallel which can lead to either circular dependencies or race conditions. Neither of which are fun to deal with. // See the docs below for more information (strongly suggest reading at least this section) // https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib-readme.html#construct-dependencies dependent.node.addDependency(provider); } }
// providerStack.ts export default class ProviderStack extends Stack { someInstance: ec2.instance; constructor(scope: Construct, id: string) { this.someInstance = new ec2.Instance(this, 'instance', {...}); } }
// dependentStack.ts export type DependentStackProps = StackProps & { providerStack: ProviderStack; } export default class DependentStack extends Stack { constructor(scope: Construct, id: string, props: DependentStackProps) { const { providerStack } = props; const { someInstance } = providerStack; const securityGroup = ec2.SecurityGroup.fromSecurityGroupId(this, 'SG', 'sg-12345', {...}); someInstance.addSecurityGroup(securityGroup); } }
This does have the drawback of not being as reusable. So for libraries, this approach would be a non-starter. However, working within the boundaries of a contained application can save lots of headaches and potentially even some build time.
Going further down the rabbit hole, we move away from passing values around in memory during the synth and build steps, instead opting toward external services. Namely, SecretsManager and ParamterStore.
To remove the need to pass the construct, let’s store the ARN in Parameter Store.
// providerStack.ts export default class ProviderStack extends Stack { constructor(scope: Construct, id: string, props: StackProps) { const ourQueue = new sqs.Queue(this, 'queue', {...}); new ssm.StringParameter(this, 'QueueArn', { allowedPattern: '.*', description: 'The queue ARN', parameterName: 'QueueArnParameter', stringValue: ourQueue.queueArn, tier: ssm.ParameterTier.ADVANCED, }); } }
Now using CloudFormation dynamic references, let’s update the dependent code.
// dependentStack.ts export default class DependentStack extends Stack { constructor(scope: Construct, id: string, props: StackProps) { const queueArn = new CfnDynamicReference( CfnDynamicReferenceService.SSM, 'ssm:QueueArnParameter', ); const targetQueue = sqs.fromQueueArn(queueArn); const ourRole = new iam.Role(...); targetQueue.grantReadWrite(ourRole); } }
We have successfully decoupled the two resources within the codebase, by abstracting away the connection via ParameterStore and a dynamic reference. This would hopefully allow us to have more freedom to add and remove dependents without having to worry about maintaining a complex dependency tree within our typing system and instead opting to rely on an external system.
Referencing secrets in Secrets Manager can be done in the same manner as Parameter Store, so rather than rehash that, I’ll leave you with some general advice. You can create your secrets in CDK. But you should never fill your secrets from within your CDK codebase. The docs for the Secret construct mention this outright. So just like with anything else, you should not be checking secret anything into your code base. Ever. Build it however, fill it manually and only load the value when you need it.
// dependentStack.ts export default class DependentStack extends Stack { constructor(scope: Construct, id: string, props: StackProps) { const apiSecret = new CfnDynamicReference( CfnDynamicReferenceService.SECRETS_MANAGER, 'ApiToken:SecretString', ); const lambda = lambda.NodeJsFuntion(this, 'myFunc', { ... environment: { API_TOKEN: apiSecret, }, }); } }
Likely over the life of your application, you’ll find your needs evolving from using the simpler methods at the beginning to the more complex ones as you progress. I am always an advocate for KISS (keep it stupid simple) for as long as possible. Only introducing complexity once it’s needed. So don’t feel you need to migrate everything as soon as possible. If what you have has been satisfactory up until now, it will likely be just as satisfactory tomorrow. Wait until you start to notice hot spots before starting to move things up in terms of complexity.
Best of luck and hope this helps!