Document your AWS CDK constructs like a pro

Jérôme Van Der Linden
10 min readNov 22, 2023

--

When building AWS CDK constructs, we generally want to publish them to npmjs and ConstructHub so that most people can find and use them. We also want to provide a nice documentation to our users, just like the CDK constructs themselves. This post will tell you everything you need to know to build great documentation for your CDK constructs.

What is a great documentation?

Before digging into the implementation details, let’s first outline what constitutes effective documentation for CDK constructs. Great technical documentation plays a vital role in conveying information about your constructs and preventing GitHub issues stemming from misunderstandings.

Just like almost any library or API, construct’s documentation should be clear, concise and avoid jargon. It should also adhere to a coherent and consistent structure. But most important and that will be the topic of this blog post, it must be up-to-date and accurate. To elaborate, the documentation must accurately reflect the current version of your construct. When incorporating code snippets or examples, they should align with the specific version of your construct and ideally be error-free when compiled.

Regrettably, when you take a snippet of code in a documentation, you copy/past it in your code and it does not even compile. Most of the time, it’s a small typo, a missing semi-colon, an old field name, … and you manage to solve the problem by yourself. But what a poor developer experience!

So a good documentation is supposed to make the developer’s life easier, and to help them develop faster.

Key concepts

Construct

I take the assumption that you know what is a Construct, else you can go read the doc if needed. This is the heart of CDK and also the main piece to document.

Named module

This is more a Typescript notion here, but it will be important later in this post. Moreover Typescript is the language used by CDK itself so we can’t really avoid it.

In CDK, there are many modules, actually one per service: aws_ec2, aws_kms, aws_s3, … You can find them all in the index.ts file in the root folder of the cdk-lib. They are declared with the following syntax (es2020):

export * as aws_kms from './aws-kms';

When your library start to grow, you may also want to organize your constructs into modules and obviously document them.

Tooling

Before explaining how to write the documentation, we need to understand the underlying tools.

jsii

jsii is the tool leveraged by CDK to make it polyglot, so that you can write your CDK code in multiple programming languages (Java, C#, Python, …). Simply execute the jsii command from the root of your project if you want to try, but it is generally done automatically for you by CDK or tools like projen.

jsii first compiles the code written in Typescript: it actually uses tsc (the standard typescript compiler) to produce the Javascript code. It then generates an assembly file, you may have seen it already in your project, it is called .jsii. It’s a big JSON file containing lots of information about your constructs, their fields, methods and … the associated documentation.

Here is an example of what you can find in this assembly file.

"project.module.MyConstruct": {
"assembly": "project",
"base": "project.module.IConstruct",
"docs": {
"example": "import { Bucket } from 'aws-cdk-lib/aws-s3 ...",
"remarks": "doc",
"see": "http://...",
"stability": "stable",
"summary": "summary"
},
"fqn": "project.module.MyConstruct",
"initializer": {
"docs": {
"stability": "stable"
},
"locationInModule": {
"filename": "src/module/lib/my-construct.ts",
"line": 45
},
"parameters": [
{
"name": "scope",
"type": {
"fqn": "constructs.Construct"
}
},
{
"name": "id",
"type": {
"primitive": "string"
}
},
{
"name": "props",
"type": {
"union": {
"types": [
{
"fqn": "project.module.MyConstructProps"
}
]
}
}
}
]
},
"kind": "class",
"locationInModule": {
"filename": "src/module/lib/my-construct.ts",
"line": 41
},
"methods": [
{
"docs": {
"stability": "stable",
"summary": "summary"
},
"locationInModule": {
"filename": "src/module/lib/my-construct.ts",
"line": 161
},
"name": "grantExecutionRole",
"parameters": [
{
"docs": {
"summary": "summary"
},
"name": "role",
"type": {
"fqn": "aws-cdk-lib.aws_iam.IRole"
}
}
],
"protected": true
},
],
"name": "MyConstruct",
"namespace": "module",
"symbolId": "src/module/lib/construct:Construct"
}

As you can see, it contains all the documentation of all constructs in your project.

jsii is actually not just a tool, it’s a toolchain. It comes with multiple tools:

  • jsii-pacmak that creates the packages in the target languages.
  • jsii-rosetta that transliterates code snippets (in docs) from TypeScript to target languages. We’ll come back to this one.
  • jsii-docgen that generates markdown API documentation (the API.mdfile). This is the file used by ConstructHub to produce the nice API reference page.
  • … and others. All these tools use the .jsii manifest file to generate their output.

jsii-rosetta

As mentioned previously, code snippets are essentials for the good understanding of your constructs. And what a best code snippet than a snippet that actually compiles against your code?

This is the job of jsii-rosetta! Use the following command to execute it:

jsii-rosetta extract --fail .jsii

This command will take the .jsii file, extract all the examples (search for example in the json above), compile them and transliterate (translate) them in all languages. If the compilation succeed, it will generate a tablet file (named .jsii.tabl.json) that will contain all the code snippets from your project in all languages.

Here is an extract of what you can find in this file:

{
"version": "2",
"toolVersion": "5.2.0",
"snippets": {
"c6f3776c5baba33bb5fdbd0216b604e4c75fc59fe5c59f8dab08731d6baca035": {
"translations": {
"python": {
"source": "from aws_cdk.aws_s3 import Bucket\n\n\nproject.module.MyConstruct(self, \"ExampleConstruct\",\n bucket=Bucket(scope, \"Bucket\")\n)",
"version": "2"
},
"csharp": {
"source": "using Amazon.CDK.AWS.S3;\n\n\nnew Module.MyConstruct(this, \"ExampleConstruct\", new ConstructProps {\n Bucket = new Bucket(scope, \"Bucket\")\n});",
"version": "1"
},
"java": {
"source": "import software.amazon.awscdk.services.s3.Bucket;\n\n\MyConstruct.Builder.create(this, \"ExampleConstruct\")\n .bucket(new Bucket(scope, \"Bucket\"))\n .build();",
"version": "1"
},
"go": {
"source": "import \"github.com/aws/aws-cdk-go/awscdk\"\n\n\nmodule.MyConstruct(this, jsii.String(\"ExampleConstruct\"), &ExampleConstructProps{\n\tBucket: awscdk.NewBucket(scope, jsii.String(\"Bucket\")),\n})",
"version": "1"
},
"$": {
"source": "import { Bucket } from 'aws-cdk-lib/aws-s3';\n\nnew project.module.MyConstruct(this, 'ExampleConstruct', {\n bucket: new Bucket(scope, 'Bucket')\n});",
"version": "0"
}
},
"location": {
"api": {
"api": "type",
"fqn": "project.module.MyConstruct"
},
"field": {
"field": "example"
}
},
"didCompile": true,
"fqnsReferenced": [
"aws-cdk-lib.aws_s3.Bucket",
"aws-cdk-lib.aws_s3.IBucket",
"project.module",
"project.module.MyConstruct",
"project.module.MyConstructProps",
"constructs.Construct"
],
"fullSource": "// Hoisted imports begin after !show marker below\n/// !show\nimport { Bucket } from 'aws-cdk-lib/aws-s3';\n/// !hide\n// Hoisted imports ended before !hide marker above\nimport { Construct } from 'constructs';\nimport * as cdk from 'aws-cdk-lib';\nimport * as project from 'project-lib';\n\nclass MyStack extends cdk.Stack {\n constructor(scope: Construct, id: string) {\n super(scope, id);\n\n // Code snippet begins after !show marker below\n/// !show\n\n\nnew project.module.Construct(this, 'ExampleConstruct', {\n bucket: new Bucket(scope, 'Bucket')\n});\n/// !hide\n// Code snippet ended before !hide marker above\n }\n}",
"syntaxKindCounter": {
"10": 5,
"79": 9,
"108": 1,
"207": 1,
"208": 2,
"211": 2,
"241": 1,
"269": 1,
"270": 1,
"272": 1,
"273": 1,
"299": 3,
"308": 1
},
"fqnsFingerprint": "80377595f9e0517e8a76011c6b7314c0dec798a4c5dfe271ff90ca6aa3827969"
},

A few interesting things to notice:

  • First, you can see all the translations available ($ is for the source: Typescript)
  • Second, you can see the location where this code comes from. Here it’s in an example (field: example) in the construct MyConstruct.
  • Third and this is probably the most important here: didCompile: true. The example code has been compiled, that’s a great news!
  • Finally you can see the full source but I will come back to this soon.

Writing documentation and examples

I went a bit far with the two json files above. You actually don’t really need to deal with them but it helps understanding how everything works.

Let’s come back to our code and see how to write documentation and examples/code snippets that will end up in these files and in ConstructHub.

There are two main places where you can document your constructs:

  • In the constructs themselves, using TSDoc on classes, methods, fields, …
  • In README.md files in each module of your library (remember the named module I described above?).

Constructs documentation

Constructs documentation is based on code comments written in your code with TSDoc. You should comment your public API: exported classes, public methods and fields, etc.

In this post, we focus on code examples. You can provide a code example using the @example tag as follows:

/**
* Creates a super S3 bucket.
*
* @example
* import { Bucket } from 'aws-cdk-lib/aws-s3';
*
* new project.module.MyConstruct(this, 'ExampleConstruct', {
* bucket: new Bucket(this, 'Bucket')
* });
*/
export class MyConstruct extends Construct {
...
}

But this example, alone, cannot compile. Some imports are missing, this is used but we’re not even in a class. If you try to run jsii-rosetta extract --fail .jsii on this code, you’ll get several compilation errors. We could complete the example with all these missing pieces but then it would not be a snippet anymore, and it would be harder for your users to focus on the important part — your construct.

To make this example compile without adding lots of boilerplate code, we need to wrap it somehow. jsii-rosetta comes with a template mechanism called fixtures. You must define your fixtures in a rosetta folder at the root of your project. Fixtures (templates) looks like standard Typescript code, for example:

import { Construct } from 'constructs';
import * as cdk from 'aws-cdk-lib';
import * as project from 'project-lib';

class MyStack extends cdk.Stack {
constructor(scope: Construct, id: string) {
super(scope, id);

/// here
}
}

Your code, the example specified in the construct comment with @example, will be inserted at the location marked by the comment/// here , and imports added at the top, which would give:

import { Bucket } from 'aws-cdk-lib/aws-s3';
import { Construct } from 'constructs';
import * as cdk from 'aws-cdk-lib';
import * as project from 'project-lib';

class MyStack extends cdk.Stack {
constructor(scope: Construct, id: string) {
super(scope, id);

new project.module.MyConstruct(this, 'ExampleConstruct', {
bucket: new Bucket(this, 'Bucket')
});
}
}

This looks like something that can compile now :) And it’s actually what you can see in the .jsii.tabl.json file, under fullSource.

You can provide a default fixture to be applied to most of (or all) your examples with a file called default.ts-fixture in the rosetta folder, and eventually create other ts-fixture files. The default is applied unless you specify otherwise using the following syntax:

/**
* Creates a super S3 bucket.
*
* @exampleMetadata fixture=imports-only
* @example
* your snippet here
*/

Using @exampleMetadata you can specify the fixture to apply. Here, it will use a file called imports-only.ts-fixture in the rosetta folder containing for example only imports:

import { Construct } from 'constructs';
import * as cdk from 'aws-cdk-lib';
import * as dsf from 'aws-dsf';

/// here

And you can define as many fixtures (templates) as you need.

To summarize, a picture is worth a thousand words (click to zoom in). You focus on the blue files, and the rest is done for you with the CDK tools (jsii and jsii-rosetta):

Modules documentation

As quickly introduced, you can create a README.md file in each module. It’s very important to have named module, like I described before, and not simple folders. Otherwise the magic won’t happen.

Let’s take the example of KMS in CDK. You can find the README.md file in the aws-cdk/packages/aws-cdk-lib/aws-kms directory (aws-kms module). It looks like a standard README with text, code snippets and so on. But there is more…

Just like our construct’s examples, we would like our code snippets in the README.md to be compiled. You cannot simply use the standard markdown, jsii doesn’t care about it:

```ts
// your code snippet here
```

Rather, you need to use something which looks like a markdown link ([text](link)) but linking to a specific kind of file. If you look at the KMS README.md file, line 48, you can see the following:

[sharing key between stacks](test/integ.key-sharing.lit.ts)

The link refers to a .lit.ts file, that actually contains the Typescript code snippet to insert in the documentation. Reading the README.md on GitHub, you can just see this link, but looking at the generated documentation on ConstructHub, you can see the snippet was actually inserted at the place where there is this link. And same on the official CDK doc.

Looking at these lit.js files, they must be in the project, within the module directory (or a subdirectory, for example test like they do on CDK, or any other name, I use examples in my project). They must also have the .lit.ts extension, you cannot use just any .ts file.

Regarding the content of these files, it is pure Typescript, but with custom comments, just like the /// here we had in the fixtures before. Unfortunately, fixtures are not leveraged in that case so you must write the complete example that will compile. And in order to display only a subset of the example in the documentation, you can use two comments: /// !show and /// !hide.

For example, in the following lit.ts file:

import { Bucket } from 'aws-cdk-lib/aws-s3';
import { Construct } from 'constructs';
import * as cdk from 'aws-cdk-lib';
import * as project from 'project-lib';

class MyStack extends cdk.Stack {
constructor(scope: Construct, id: string) {
super(scope, id);

/// !show
new project.module.MyConstruct(this, 'ExampleConstruct', {
bucket: new Bucket(this, 'Bucket')
});
/// !hide
}
}

Only the part between these two comments will be shown in ConstructHub.

Back to the KMS example, you can observe the integ.key-sharing.lit.ts file and what is actually displayed in ConstructHub.

Apart from the fixtures, it works pretty much like the construct’s comments:

jsii generates the assembly file, that will contain the following piece:

"submodules": {
"project.module": {
"locationInModule": {
"filename": "src/index.ts",
"line": 5
},
"readme": {
"markdown": "The content of your README file with some code snippet links: [example](examples/my-construct.lit.ts)"
},
"symbolId": "src/module/index:"
},
}

jsii-rosetta extract will take this and generate the tablet file, that will contain the transliteration (note the lit in this world) for all languages. It’s actually the same as before except for the location which is now :

    "location": {
"api": {
"api": "moduleReadme",
"moduleFqn": "project.module"
},
"field": {
"field": "markdown",
"line": 21
}
},
"didCompile": true,

To summarize, we have almost the same picture as before. You focus on the blue files and the rest is done for you:

Conclusion

In this blog post, I have elucidated the utilization of CDK and its associated toolchain, primarily jsii and jsii-rosetta, in the process of generating the documentation available on ConstructHub. I have also outlined the essential files that need to be authored and maintained, as well as those automatically generated by these tools. My goal was to provide you with a comprehensive understanding so that you can produce documentation of higher quality. This includes ensuring that your code snippets are accurate, facilitating experience for developers who can effortlessly copy and paste them into their CDK stacks.

--

--

Jérôme Van Der Linden

Senior Solution Architect @AWS - software craftsman, agile and devops enthusiastic, cloud advocate. Opinions are my own.