Build a real-life serverless app with AWS Amplify
Right off the bat: serverless is an amazing technology. It lets you build complex apps very quickly and cost efficiently because you concentrate only on the stuff that matters: your application.
I’ve used it to build my own wedding website and a couple of other personal projects. However, I was yet to created one with a real business case behind it. Turns out, I had an opportunity to correct this situation… so I jumped on it!
My (now) wife and I are both French and leaving in the beautiful city of Edinburgh, Scotland. A year ago, she decided to create her own travel agency, called Ooh My World Voyages Limited (or OMWVL) which is aimed at French speaking people who want to visit Scotland. The company is now 1 year old and spring is the start of the high season, i.e. lots of customers. As we got married in May — and went straight on honeymoon for 3 weeks after that— she needed a tool to schedule text reminders (e.g. train schedules, tour bookings, etc.) to be sent at specific date and time.
I am a software engineer for Cloudsoft and every day, I get to play with cloud services for our customers, especially on AWS. So when I heard this, I told her:
What about building you something very quickly? It’s going to be rough, but functional.
And she said yes! (punt intended ;)) My plan was to leverage AWS Amplify to accelerate the development of this small app. Bonus for me: I’ve never used it before, that was a new cool stuff to learn 🙌
Let’s start by doing things right from the beginning, shall we? Serverless on AWS usually involves a combination of AWS services: Lambdas, DynamoDB table(s) and S3 for the basics. I also threw AppSync in the mix because GraphQL is awesome and I wanted to experiment with the offline capacity.
The requirements for the app were:
- CRUD customers (name and phone number)
- CRUD reminders (message, date, time and customer associated to it)
- Send text messages to customers at the time of the scheduled reminders
It was fairly easy to translate this into a simple architecture diagram: I needed a PWA app hosted on S3, talking to an AppSync endpoint connected a DynamoDB table. Then for each reminder created, a corresponding CloudWatch scheduled event would trigger a lambda that sends the text via SNS:
The next step was the data modelling of the DynamoDB table. Thanks to the excellent last year’s Re:Invent deep-dive video on DynamoDB (FYI: I highly recommend you to watch it, it is mind-blowing) I knew more or less where I was going. I started by listing all the access patterns that I required:
Then tried to map them into a single table. I ended up with the following mapping, thanks to the help of very nice and helping AWS chaps on Twitter. This table also has 2 GSIs that I used as sparse indexes, one for the customers and one for the reminders:
Building the app
I mentioned earlier AWS Amplify. This is an incredible tool that really accelerates the dev-cycle for serverless apps. Although, it is not exempted of flaws which I learned the hard way. But it’s open source and moving rapidly so go contribute if you can! 🤙
Database & API
The first thing I did was to create the DynamoDB table. This was super easy thanks to the wizard presented to you when you use the following command:
amplify add storage
Under the hood, AWS Amplify leverages AWS CloudFormation to create the cloud resources. Every database, function, api, auth, hosting or storage you add in your project will have a corresponding CloudFormation template resulting of nested stacks in your AWS account.
I managed to create my table in less than 1 minute, based on my data modelling. So far so good!
Regarding the API, my plan was to use AppSync to get a GraphQL endpoint (Yay, no REST endpoints to manage!). Amplify supports GraphQL endpoint of course, and is even able to generate tables + resolvers + complementary typescript code based on a GraphQL schema 🎉
Hum… hang on! Why the hell did I bother to manually create the table instead!?
There you have it ladies and gentlemen, my first issue with Amplify!
Problem is: each type (my “customer” and “reminder” in this case) needs to be annotated with @model which creates a separate table 😫
As I am using a single table, my schema doesn’t fit the bill and thus I couldn’t use the auto-generation feature of Amplify (Ooooooh). However, the tool supports custom resolvers so I could still create these by hand (Aaaaaah).
amplify add api
After the GraphQL endpoint has been created with the above command (again, simple by following the wizard), I started the slow and painful process of manually writing my resolvers, corresponding to the access pattern described previously.
Great, I now have a place to store data and retrieve it from my yet-to-be-built PWA app. Let’s move on to the next part.
I didn’t want my app to be open to the world so I added authentication into my project with Cognito:
amplify add auth
This configure a user pool and all the necessary moving parts. However, I also wanted my GraphQL endpoint to be protected. It couldn’t have been more easy:
amplify update api lets you select (if you have multiple ones) and update the configuration of your API endpoint (As a matter of fact, every resource can be updated this way)
Because I created my auth resource before hand, Amplify knew about it and configured AppSync to use that, just by selecting it from a dropdown list.
As described on the architecture diagram, my app needs 2 lambdas to work:
- SendReminder —sends the SMS to the customers, triggered by CloudWatch scheduled events.
- StreamRecord — processes DynamoDB records, as they get inserted/updated or deleted. This lambda is responsible to create/update/delete CloudWatch scheduled events based on corresponding reminders stored within DynamoDB.
I created the first one with the command:
amplify function add
This function is very straight forward: it takes a custom event containing a message and phone number, then use the AWS SDK to send a text to the customer with SNS.
The second lambda however is more complex. It is triggered by a DynamoDB stream event. I.e. every time a CRUD operation is performed on a row, the lambda is executed. The event contains the previous and new state of the corresponding streamed item.
I created it using the same process than the first function.
The code tries to process each record. But first, it needs to identify which type of records it is. Methods processCustomers and processReminders filter these types and process them accordingly:
- For each INSERT operation on a reminder, a corresponding CloudWatch scheduled event is added, with one target pointing to the SendReminder lambda. This code path is also followed by each MODIFY operations.
- For each REMOVE operation on a reminder, the corresponding CloudWatch scheduled event and target are removed.
- For each REMOVE operation on a customer, all related data (i.e. reminders associated) are removed.
In the case of reminder records, if the execution is successful, the lambda also updates the corresponding customer row with the count of reminders per customer.
Alright, using DynamoDB stream is nice for decoupling the data from the processing of it, but it comes at a cost. Even though the app is successful at creating/updating/deleting records in the DB, it doesn’t necessarily means the corresponding schedule event (i.e. what actually sends SMS) has been processed. The UI — and therefore the end-user — doesn’t have any knowledge of what happens in the background, as it talks only to the database.
Your error handling is only as good as your monitoring.
I corrected this by creating CloudWatch alarms, publishing to an SNS topic, triggered by custom metrics that simply watch the lambda logs and look for specific exceptions thrown by my functions.
Thankfully, when amplify creates a cloud resource with
amplify add ... command, it generates a CloudFormation template to automate the creation of this resource in the cloud. This template can be modified to include anything you want. In this case, I added my custom resources to the template of StreamRecord lambda.
The topic has one subscription (personal email address) so if something goes wrong, I get emails about it. I can then come to the AWS console and start investigating.
In the spirit of doing something quick and dirty, I decided to go for Angular 7 — as opposed to the popular framework React — mostly because I know it (and love it! I know, controversial). Setting up a new app was very easy with the Angular CLI:
ng new scotlander
Next step was to integrate Amplify into the app. Again, fairly easy by following the documentation.
Beyond that, there isn’t much to say about the UI. For the MVP, I used plain simple HTML with no bells and whistles: no CSS, no fancy widgets (i.e. date or time picker which makes things slightly more complicated for the end-user)
It was rough, as advertised. But most importantly, it worked 🙌
The only thing left to do was to add the hosting feature with
amplify add hosting and off I went. I rolled out the first version one week before the wedding, after a short phase of testing.
Spoiler alert: deploying all of this is as easy as doing
They are couple of things I would like to do, in random order:
- Better UI (although, I started on that already, see below)
- Improve monitoring. I currently watch for issues in my code but what about SNS failing to send the SMS? It’s easy to think that AWS managed services are reliable but they can fail sometimes! Always architect for failure.
- Offline support using the Apollo client.
- GraphQL request caching (using Apollo client again).
- Proper PWA setup using Angular package
- Feed custom metrics back into the PWA app. In a combination of sending emails, I can have another lambda that writes data back into my DynamoDB table to inform the end-user.
Let me share some statistics about this project:
- It took me ~30 hours to architect and build the first MVP, elapsed over ~2 weeks (a good chunk of this was working out the data modelling and learn about AWS Amplify itself)
- The app is live for a month and a half now, and has sent more than 30 reminders flawlessly.
- Excluding the cost of sending SMS, the app runs for FREE thanks to the always free AWS free-tier.
- Total cost of running this app so far: $0.94 🙌🎉🚀
Oh, did I mention that Amplify is not only a CLI, but a service on the AWS console that allows you to create a CI/CD pipeline for you, with simple clicks, making the solution pretty good for both rapid prototyping AND production app ? 😄
Anyway, I hope I managed to convince you that it is simple a quick to develop and deploy serverless app with AWS Amplify (or at least, get you excited about it). So get out there and give it go!
Thank you for reading!