DEV Community

Cover image for Correctly defining CDK dependencies in L3 constructs
Daniel Schroeder for AWS Community Builders

Posted on • Edited on

Correctly defining CDK dependencies in L3 constructs

When creating L3 constructs for the AWS CDK, the easiest thing to get wrong is defining dependencies. And with wrong dependency definitions you make it hard to impossible to use your package. This post will show how to correctly define CDK dependencies.

As a CDK user, you already know, all CDK core packages have to be of the same version or you will get cryptic errors such as:

unable to determine cloud assembly output directory. Assets must be defined indirectly within a "Stage" or an "App" scope
Enter fullscreen mode Exit fullscreen mode

or

Types of property 'node' are incompatible... Types have separate declarations of a private property 'host'.
Enter fullscreen mode Exit fullscreen mode

So what you want to avoid when publishing L3 constructs, is to directly depend your package on any version of the core CDK packages. Still, this is the case in almost every L3 construct I have seen. I'm assuming this is because the core packages themselves do it like this and developers learn from reading code.

Usually you find packages having either exact or caret versions in their dependencies. Also, in most cases, these definitions are accompanied with the same items in the peerDependencies. Let's analyze these two setups and what the result for the end-user is going to be:

Dependencies with caret version

{
  ...
  "dependencies": {
    "@aws-cdk/aws-lambda": "^1.23.0"
  },
  "peerDependencies": {
    "@aws-cdk/aws-lambda": "^1.23.0"
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

The caret definition means, install the latest minor compatible to the given version. That is everything < 2.0.0. So until CDK 2 drops, this will install the very latest package for aws-lambda.

The end-user now can only install your package, when all application dependencies refer to the latest CDK packages as well. If CDK 1.70.0 or any other older version is used, there is no way the user can install your package without causing incompatibility between core packages. The solution from user perspective is to upgrade the CDK and deal with the potentially introduced breaking changes.

Dependencies with exact version

{
  ...
  "dependencies": {
    "@aws-cdk/aws-lambda": "1.80.0"
  },
  "peerDependencies": {
    "@aws-cdk/aws-lambda": "1.80.0"
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

A package with this dependencies definition of course is only compatible with exactly one version of the CDK. This means your users cannot upgrade the CDK without upgrading your package and vice versa. To make your package usable by future CDK versions you need to release new versions of your package for every CDK release. Most probably you do this automated. And either you have a mapping between CDK version and your package version in your docs or you use the same exact version as the upstream packages, which renders the information (semver) of your version string useless. How do you progress your own code? How do you communicate bug-fixes or breaking changes? And let's not forget, you waste computational resources for compiling, transferring and storing your build artifacts without actual change.

The problem though, is not the format of your dependency definition (exact vs. caret) - the problem simply is: You have dependencies.

And as simple as that problem statement, is the solution: Just don't.

Thou shalt not list CDK core packages in thy dependencies

Instead, list them in the peerDependencies and devDependencies. You should use caret versions, defining the minimum version required by your package. Typically this should be the version when all your CDK dependencies went stable. If you don't know or don't care, use ^1.0.0.

{
  ...
  "devDependencies": {
    "@aws-cdk/aws-lambda": "^1.0.0"
  },
  "peerDependencies": {
    "@aws-cdk/aws-lambda": "^1.0.0"
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

You need them in the devDependencies to build your package with jsii and you need them in the peerDependencies to let the user know what packages need to be installed along with your package.

Now, when a user installs your package, what happens depends on the used language and package manager. Either way, the user needs to define the peer-dependencies as dependencies of the application, e.g. in the package.json or requirements.txt.

npm version < 6 will warn about missing peer dependencies:

npm WARN cdk-awesome-package@1.2.3 requires a peer of @aws-cdk/aws-lambda@^1.0.0 but none was installed.
Enter fullscreen mode Exit fullscreen mode

The same goes for yarn:

warning " > cdk-awesome-package@1.2.3" has unmet peer dependency "@aws-cdk/aws-lambda@^1.0.0".
Enter fullscreen mode Exit fullscreen mode

In npm 6 for some reason this is missing and the user will only know about the missing dependency when the application is ran. That's fine though, the error message is clear about what package needs to be installed. An entry in the package.json needs to be made:

{
  ...
  "dependencies": {
    "@aws-cdk/aws-lambda": "1.70.0",
    "cdk-awesome-package": "^1.2.3"
  },
  ...
}
Enter fullscreen mode Exit fullscreen mode

Starting with npm version 7 the handling of peer dependencies has changed - they will be automatically installed and you cannot override the version in the dependencies section. Instead the user now needs to add it to the overrides section.

{
  "dependencies": {
    "cdk-awesome-package": "^1.2.3"
  },
  "overrides": {
    "@aws-cdk/aws-lambda": "1.70.0",
  }
}
Enter fullscreen mode Exit fullscreen mode

Alternatively the user can also just switch back to the old behaviour by setting legacy-peer-deps=true in the .npmrc.

All other languages supported by jsii don't support peer dependencies. For these languages they are converted to normal dependencies. pip correctly interprets the caret version definition of ^1.0.0 and in return you would install the latest version. For the dotnet package, jsii converted ^1.0.0 to 1.0.0... which in the end really doesn't matter. Because this can and has to be overridden by the user anyway.

With pip the user can override the version of dependencies by simply adding them to the applications requirements.txt or setup.py, e.g.:

aws-cdk-aws-lambda==1.70.0
cdk-awesome-package>=1.2.3
Enter fullscreen mode Exit fullscreen mode

In C# the user can also override the version in the project file, e.g.

<ItemGroup>
  <PackageReference Include="Amazon.CDK.AWS.LAMBDA" Version="1.70.0" />
  <PackageReference Include="CDK.Awesome.Package" Version="1.*" />
</ItemGroup>
Enter fullscreen mode Exit fullscreen mode

I haven't checked Java but I assume the same can be archived in a gradle file.

And there you go. A CDK L3 construct working with any version of core CDK packages.


Update 2021/04/17

But what about breaking changes?

I've seen some developers are automatically releasing new versions of their L3 constructs for every new CDK release for the reason of compatibility, especially when experimental CDK components are involved. I strongly disagree with this approach.

First of all, if an L3 uses experimental features, then the L3, as a consequence, is experimental. It should be used with caution and breaking changes should be expected with every minor update. Most likely it also should not be used in production, unless the user knows what she/he's doing and pays the proper attention. Using experimental features means, the user might not be able to upgrade without destroying/recreating related infrastructure.

Next: Ensuring compatibility with future versions of a framework is not the responsibility of a package author. If there are breaking changes, of course things will fail. The same would be true if the user directly used experimental CDK features. The responsibility of catching these is not with the L3 author, but with the user of the construct. Every user needs to be aware that the smallest change needs testing. Of course, when there are package changes, the app needs to be deployed in a test environment, ran with diff and ideally even has proper jest tests in place, before it reaches your prod environment.

You cannot solve testing for the user. The best you can catch by building a package per CDK version is a change in the API signature. Maybe an option has been removed or renamed and therefore your code is not compatible and won't compile. The real danger though lies in functional changes, that make the code run, but behave differently. A very good example for this would be CDK version 1.75.0, which included the following change:

efs: keyId property uses the ARN instead of the keyId to support cross-account encryption key usage. The filesystem will be replaced.

This is the real danger with experimental packages and you cannot protect the user from this by building a new version of your package.


Also, what's exactly the benefit of building new packages for every release? You ensure it still builds. So what if it doesn't? Your build fails, you'll be notified by your action/workflow and you need to adjust the code to match the new API, because building new packages does not automagically fix the problem. Let's assume you're knee-deep involved in other projects or actual life and you don't have time to fix it for some time.
Consequence: No user will be able to upgrade the core CDK packages until you fixed your code and released a compatible package.

Now imagine you had not auto-built new packages but instead used my suggested best practice? The package will not be compatible with the latest CDK version.
Consequence: No user will be able to upgrade the core CDK packages until you fixed your code and released a compatible package.

The only difference would be, that you won't be notified by your failed pipeline/action and wouldn't be aware there is a breaking change. And this can easily be fixed by testing your package for new CDK releases.

So instead of building and publishing new packages for absolutely no reason, you should test your package against every new CDK release.


What about projen?

Since 1st of March 2021 you can set cdkDependenciesAsDeps: false in your .projenrc.js to prevent projen from adding the CDK packages as dependencies and only list them as peerDependencies.

Top comments (0)