Moving from AWS Amplify to the AWS CDK
When I was a kid my brother and I rather enjoyed a board game called Scotland Yard. It has a rather simple goal, if you were the thief you were trying to avoid being caught by the cops. If you were a cop, your job was to catch the thief. The mechanics required some logical thinking along with guess work and understanding your opponents. Like Texas Hold-em poker, it's not just about the cards in your hand, you had to play your opponent.
Back at the end of October 2020 I decided to try to recreate this as a simple web-based game. Among Us showed us that people didn't have to have AAA production values and depth, but that a simple mechanic and engaging social play could be hugely popular.
If you'd like to follow along with this, the code this post is about can be found here.
If you'd like to see what's already been built, check it out here.
Bootstrapping with Amplify
Like a lot of my side-projects, I didn't want to expend a lot of energy on the minutiae of API development, I was more interested in the UI and getting it in front of people to play ASAP. AWS Amplify felt like a great fit, since it gave me a very easy way to create an API that allowed for some flexibility.
I started with just a simple API definition. I had a basic game state object I called "Game". I stripped the default schema.graphql of the @Model attribute and then implemented my own Lambda Function resolvers for the 6 mutations and queries I needed.
However, early on I found a little issue... I needed a GSI on the DynamoDB table for the "Game" (to find any Games that are in a 'Waiting Room' state). I could see the CloudFormation templates for the DynamoDB table but editing them directly didn't work as changes we're overwritten by the Amplify toolset. I reached out to the Discord channel and was told to just edit the table directly to add the GSI. Gaaarr.... I didn't like that but also didn't feel like spending a lot of time to find a better solution. So I did as suggested and added the GSI manually. I dug myself into a little hole, but it wasn't too deep.
I continued the development effort and things were fine. After most of the functionality was there I ran into my second issue I couldn't resolve through the toolset: I wanted my DynamoDB table to have a sort key. Again, I couldn't find a good way to edit this information through the Amplify toolset. I had to fall back to managing the table outside of Amplify.
Now I felt like I needed to reconsider Amplify. I wasn't using much of it at this point and now it was directly causing me more pain than it was worth. I wasn't using a lot of the Amplify toolset (just one api and a few functions) so now was the time before I kept digging any more holes.
Transitioning to the CDK
I saw a publication on the Construct Catalog of Ken Winner's AppSync Transformer Construct for AWS CDK. Although it couldn't replace all the functionality of Amplify it already covered the stuff I was using.
So, I sat down one evening, bootstrapped a new CDK project using projen and started typing away.
About 2 1/2 hours later I had the following code:
export class EthrwarsStack extends Stack { private gameTable!: Table; private transformer!: AppSyncTransformer; private waitingRoomIndexName!: string; constructor(scope: Construct, id: string, props: EthrwarsStackProps) { super(scope, id, props); this.createApi(); this.createTable(); for (const functionName of props.functions) { this.createDataSource(functionName); } } private createTable() { this.gameTable = new Table(this, 'gametable', { partitionKey: { name: 'id', type: AttributeType.STRING }, sortKey: { name: 'sk', type: AttributeType.STRING }, billingMode: BillingMode.PROVISIONED, readCapacity: 5, writeCapacity: 5, stream: StreamViewType.NEW_IMAGE, }); this.waitingRoomIndexName = 'waitingRoom'; this.gameTable.addGlobalSecondaryIndex({ indexName: this.waitingRoomIndexName, partitionKey: { name: 'status', type: AttributeType.STRING }, readCapacity: 5, writeCapacity: 5, projectionType: ProjectionType.ALL }); } private createApi() { this.transformer = new AppSyncTransformer(this, 'game-api', { schemaPath: path.join(__dirname, '../../amplify/backend/api/ethrwars/schema.graphql') }); } private createDataSource(functionName: string) { const lambda = new NodejsFunction(this, functionName + '-lambda', { entry: path.join(__dirname, `../../amplify/backend/function/${functionName}/src/index.js`), environment: { TABLE: this.gameTable.tableName, INDEX_NAME: this.waitingRoomIndexName, USES_SK: 'true' } }); this.transformer.addLambdaDataSourceAndResolvers(functionName + '-${env}', `${functionName}_datasource`, lambda, {}); this.gameTable.grantReadWriteData(lambda); } }
(In case you're wondering, 'Ethrwars' is a generic name I've given to many side-projects over the years).
A few things to note:
- The stack is handed a list of 'functions'. This is determined by the following code:
const functionNames = readdirSync(path.join(__dirname, '../../amplify/backend/function'));
I'm using the directory structure that Amplify defines to determine what to use. No migration of code to a new directory structure and no defining what functions already exist, it just uses them all.
- All Lambda Function handlers I'm creating are pointing at the existing code written and stored for the Amplify toolset. In other words, I can keep using the two in parallel.
- Since the new CDK code introduces a new feature - the use of an SK on the DynamoDB table - I pass a feature flag to the handler through the environment variable "USES_SK" to let keep it backwards compatible with the Amplify-controlled Table.
- I wouldn't call this "complete" and "production worthy" code. This is still just a proof of concept for a game. I would make many minor changes to the above code, like allowing many parameters to be driven from configuration instead of hardcoded.
- I am still using the Amplify CICD toolset for the front-end build and deploy. If I rework that code later as CDK code, maybe I'll write a follow-up post.
Update:
A point which was clarified on Twitter: GSIs and table with Sort Keys are available if you use the @model directive. I chose not to as I wasn't doing CRUD operations on the schema.
Conclusion
I like AWS Amplify. I think it's a great toolset that allows you to get up and running with some sophisticated APIs without having to invest a lot of time and resources. However, it is rather opinionated and if you run against its limitations, it might be tempting to eject. In my case I was able to eject pretty early and it was rather painless thanks to the good work of 3rd party construct developers making things easy.