DEV Community

Gerald Vrana
Gerald Vrana

Posted on

How to Resolve Tightly Coupled Dependencies in AWS CDK

Introduction: The dependency hell

If you've ever worked with AWS CDK or CloudFormation, chances are high you've stumbled into a familiar problem: stack dependencies

One stack exports an output-value, another one imports it, you make a small innocent change and suddenly Boom 💥

Your deployment suddenly does not work anymore.

Yeah, I've been there too lately, it's one of those moments where you realise how easy it is for tightly coupled stacks to create hidden deployment traps, circular dependencies, deadlocks, and redeploy nightmares.

In this post, I'll walk you through an example of how to refactor your CDK code to make it more loosely coupled, reducing cross stack references and giving you more flexibility in deploying and evolving your infrastructure safely.

A hands-on example

Imagine following architecture...

You have a Stack called UserService which stores and processes User-Information. Suddenly there is a new requirement, you now need to process User-Information in your OrderService Stack too.

The simplest solution is to expose the DynamoDB table you've created, and forward it from one stack to another.

Example Architecture, tightly coupled

The line between the stacks indicates their dependency on the DynamoDB table.

In your code this would look like following:

// bin/example-app.ts

import * as cdk from 'aws-cdk-lib';
import {UserServiceStack} from '../lib/user-service-stack';
import {OrderServiceStack} from '../lib/order-service-stack';

const app = new cdk.App();

const userServiceStack = new UserServiceStack(app, 'UserService');

new OrderServiceStack(app, 'OrderService', {
    usersTable: userServiceStack.usersTable
});
Enter fullscreen mode Exit fullscreen mode
// lib/user-service-stack.ts

import * as cdk from 'aws-cdk-lib';
import {Construct} from 'constructs';
import {AttributeType, ITableV2, TableV2} from 'aws-cdk-lib/aws-dynamodb';

export class UserServiceStack extends cdk.Stack {

    public readonly usersTable: ITableV2;

    constructor(scope: Construct, id: string, props?: cdk.StackProps) {
        super(scope, id, props);

        this.usersTable = new TableV2(this, 'Users', {
            tableName: 'Users',
            partitionKey: {
                name: 'PK',
                type: AttributeType.STRING,
            }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode
// lib/order-service-stack.ts

import * as cdk from 'aws-cdk-lib';
import {ITableV2} from 'aws-cdk-lib/aws-dynamodb';
import {Construct} from 'constructs';
import {NodejsFunction} from 'aws-cdk-lib/aws-lambda-nodejs';

interface OrderServiceStackProps extends cdk.StackProps {
    usersTable: ITableV2;
}

export class OrderServiceStack extends cdk.Stack {
    constructor(scope: Construct, id: string, props: OrderServiceStackProps) {
        super(scope, id, props);

        const readUser = new NodejsFunction(this, 'readUser', {
            entry: './lib/lambdas/readUser.ts',
            environment: {
                USER_TABLE_NAME: props.usersTable.tableName,
            },
        });

        props.usersTable.grantReadData(readUser);
    }
}
Enter fullscreen mode Exit fullscreen mode

So far, so good. Everything’s working smoothly... right?

Right.

But then, imagine this:

A bug sneaks in. Suddenly, all your user data is corrupted. Disaster! Panic! Chaos!

Now we need to move fast to contain the damage.

Time to roll out the backup (you did make one, right?), update the import in UserService.

Redeploy and everything should be good again, right?

Nope.

CloudFormation Error

Okay, okay... let's slow down for a moment and take a closer look at what just happened.

What happened?

Since we restored the backup in AWS, a new DynamoDB table was created.
Now, we wanted to use that restored table because it still contains uncorrupted data.

To do this, we had to update our code. Instead of creating a new resource,
we are import the existing one.

We achieved this using the .fromTableAttributes() method (or any other function that creates a Proxy Object).

// lib/user-service-stack.ts

import * as cdk from 'aws-cdk-lib';
import {Construct} from 'constructs';
import {AttributeType, ITableV2, TableV2} from 'aws-cdk-lib/aws-dynamodb';

export class UserServiceStack extends cdk.Stack {

    public readonly usersTable: ITableV2;

    constructor(scope: Construct, id: string, props?: cdk.StackProps) {
        super(scope, id, props);

        this.usersTable = TableV2.fromTableAttributes(this, 'Users', {
            tableName: 'UsersBackup'
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Why is this causing an issue?

Lets checkout the synthesised template cdk.out/OrderService.template.json

{
  "Type": "AWS::Lambda::Function",
  "Environment": {
    "Variables": {
      "USER_TABLE_NAME": {
        "Fn::ImportValue": "UserService:ExportsOutputRefUsers0A0EEA89A1309EA5"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

In the Environment definition of our Lambda function, we see that USER_TABLE_NAME is not a string, but a intrinsic function (CloudFormation).

This approach isn't necessarily bad, but in our case it causes problems, because it tries to import an output from the UserService stack.

Since we have changed the DynamoDB table, this setup now breaks due to an AWS constraint.

After another stack imports an output value,
you can't delete the stack that is exporting the output
value or modify the exported output value.

All the imports must be removed before you can delete
the exporting stack or modify the output value.

Read more about Fn:ImportValue and it's constraints

How can we fix or even mitigate such issues?

Short answer:
Try to prevent dependencies between stacks, if it is possible.

Since we only need the name of the DynamoDB table for importing and accessing the table, let’s use this as our dependency.

(Yes, it’s still a dependency, but now it’s less tight)

// bin/example-app.ts

import * as cdk from 'aws-cdk-lib';
import {UserServiceStack} from '../lib/user-service-stack';
import {OrderServiceStack} from '../lib/order-service-stack';

const USERS_TABLE_NAME = 'UsersBackup';

const app = new cdk.App();

new UserServiceStack(app, 'UserService', {
    usersTableName: USERS_TABLE_NAME,
});

new OrderServiceStack(app, 'OrderService', {
    usersTableName: USERS_TABLE_NAME,
});
Enter fullscreen mode Exit fullscreen mode
// lib/user-service-stack.ts

import * as cdk from 'aws-cdk-lib';
import {Construct} from 'constructs';
import {TableV2} from 'aws-cdk-lib/aws-dynamodb';

interface UserServiceStackProps extends cdk.StackProps {
    usersTableName: string;
}

export class UserServiceStack extends cdk.Stack {

    constructor(scope: Construct, id: string, props: UserServiceStackProps) {
        super(scope, id, props);

        TableV2.fromTableAttributes(this, 'Users', { 
            tableName: props.usersTableName
        });
    }
}
Enter fullscreen mode Exit fullscreen mode
// lib/order-service-stack.ts

import * as cdk from 'aws-cdk-lib';
import {Construct} from 'constructs';
import {NodejsFunction} from 'aws-cdk-lib/aws-lambda-nodejs';
import {TableV2} from 'aws-cdk-lib/aws-dynamodb';

interface OrderServiceStackProps extends cdk.StackProps {
    usersTableName: string;
}

export class OrderServiceStack extends cdk.Stack {
    constructor(scope: Construct, id: string, props: OrderServiceStackProps) {
        super(scope, id, props);

        const userTable = TableV2.fromTableAttributes(this, 'Users', { 
            tableName: props.usersTableName
        });

        const readUser = new NodejsFunction(this, 'readUser', {
            entry: './lib/lambdas/readUser.ts',
            environment: {
                USER_TABLE_NAME: userTable.tableName,
            },
        });

        userTable.grantReadData(readUser);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now our architecture looks like following:

Example Architecture, loose coupled

And if we take another look at cdk.out/OrderService.template.json, we will see that the cross stack reference is gone and we are prepared for our next production incident! 🎉

{
  "Type": "AWS::Lambda::Function",
  "Environment": {
    "Variables": {
      "USER_TABLE_NAME": "UsersBackup"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

I hope you found this walkthrough helpful and that it gave you some practical ideas for managing dependencies in AWS CDK.

Reducing tight coupling can save you a lot of headaches down the road, and even small changes in how you structure your stacks can make deployments smoother and safer.

Thanks for reading, and happy coding!

Top comments (0)