At Simply Anvil, we make extensive use of Serverless architecture, especially on AWS. During the last couple of years, we learned a lot from running multiple production systems based partially or entirely on Serverless. We carved out our own best practices around authentication, security, performance, development agility, code reuse, Continuous Integration (CI) and Continuous Delivery (CD).

The Language

Although we love Python, we tend to opt for Node.js here.  With a massive NPM ecosystem, an abundance of skilled JavaScript developers and solid tooling made it a good fit.

Standardising on a language has its benefits, but if the only tool you know how to use is a hammer, everything looks like a nail. Pick the right tool for the job.

So, we opt for TypeScript, which allows us to use stricter typing along with latest generation features. As we wrote all our code in TypeScript using es2017+ features, we could easily compile to Node v6. When Node 8.10 became available in AWS Lambda, we could change the output config to take advantage of the new Node version; Thus creating a healthy balance between bleeding edge and pliability.

The Framework

Using a well-established framework saves you much headache. Don't reinvent the wheel here, as there are numerous developers actively updating a framework; chances are they have a higher developer velocity than you. Skip writing the boilerplate and use a framework.

The Serverless Framework is well maintained, has a good track record with the community and is opinionated enough to allow the newbies to jump right into the thick of it.

Basics

Ensure you have the latest version of node installed, and run the following to install the Serverless Framework:
npm install -g serverless

Create your function

One thing we love about the Serverless Framework is the mono-repo concept. One to house all your related functions, to allow maximum code re-use (services and more) between related functions without hoisting them into their repos.  A good rule of thumb here is to break repos up using banded contexts, similar to micro-services. It's important to note, that Serverless functions by themselves should be fit for purpose, and form part micro-services, rather than be micro-services themselves.

Create script

serverless create --template aws-nodejs-typescript --path service-folder --name serviceName

Install all NPM dependencies

npm install

Custom .gitignore

Often neglected, this is step something that we usually try and do first. A simple copy and paste from related projects should suffice.

# package directories
node_modules
jspm_packages

# Serverless directories
.serverless

# Build directories
.webpack
.build

coverage
npm-debug.log*
yarn-error.log
server.js*
.DS_Store
dist
env.yml

# Editor settings
.idea
.vscode

# Ignore compiled js files only
*.js
*.js.map
!jest.config.js
!webpack.config.js
!source-map-install.js

A more extensible folder structure and naming convention

The primary serverless scaffold leaves a single function in the project root. Not only is this not very descriptive, but it can also start to clutter the root quite quickly. Therefore we recommend creating a functions folder that would house all your projects function files. A more descriptive naming convention advisable. It makes more sense to reference functions/functionName.handler, especially when using middleware and more.

mkdir functions and move and rename /handler.ts to /functions/hello.ts

Change handler name in functions/hello.ts to a generic handler

import { APIGatewayProxyHandler } from 'aws-lambda';

export const handler: APIGatewayProxyHandler = async (event, context) => {
  return {
    statusCode: 200,
    body: JSON.stringify({
      message: 'Go Serverless Webpack (Typescript) v1.0! Your function executed successfully!',
      input: event,
    }),
  };
}

Update serverless.yml functions path and name to reflect latest changes.

functions:
  hello:
    handler: functions/hello.handler
    events:
      - http:
          method: get
          path: hello

Set serverless defaults:

Also a step that can trip someone up if they work in various regions etc. We recommend adding the following from the start and modifying as you go.

provider:
  name: aws
  runtime: nodejs10.x # Latest version supported
  region: eu-west-1 # Your chosen region
  memorySize: 128 # Max memory
  timeout: 3 # In seconds
  stage: dev # default stage
  profile: <your profile> # Default profile to use
  versionFunctions: false

We prefer setting a default stage along with a default profile. You should be using multiple profiles for accounts, whether those are clients or just separate accounts for your project's stages. You don't accidentally want to deploy to the wrong account. We also don't set up a default account.

Pro tip: Think about your repo and function naming conventions, as you can quickly see your serverless function list grow, especially if you consider the various dev, test, QA and prod environments. We tend to prefix all serverless Git repos with "lambda-" to make it more descriptive for developers/maintainers, but we drop the prefix in the serverless.yml to make the generated names more readable in AWS.

Test your config
serverless invoke local --function hello --data "{\"hello\": \"world\"}"

Create a test event

You want to make the testing of functions easy and repeatable. For this, we tend to put our test payloads in the repo, instead of the AWS console. This way another dev can quickly see, test and evaluate a function themselves. As a rule of thumb, we put all our related test events in /events.

events/hello.json with

{
    "hello": "world"
}

Test with:
serverless invoke local --function hello --path events/hello.json

Quick script running

No-one wants to go and manually type out these long commands in the terminal. Lock it down and allow your fellow developers to run them easier with package.json scripts.

Add your first test script to package.json for ease of use:

...
"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1",
  "local:hello": "serverless invoke local --function hello --path events/hello.json"
},
...

And run with npm run local:hello

Pro tip: If you do want to test locally with some AWS account and services using profile credentials, you'll see the invoke bombs. The profile parameter will be ignored as per the official documentation:
Serverless Framework Commands - AWS Lambda - Invoke Local

We found setting an env variable for the profile before running works:
env "AWS_PROFILE=<your profile>" serverless invoke local --function hello --path events/hello.json

Code Linting

Linting helps to spot some gremlins in the code, as well as ensures some degree of consistency and attention to detail.

npm install --save-dev tslint tslint-config-airbnb

Create tslint.json with the following code:

{
  "extends": "tslint-config-airbnb",
  "linterOptions": {
    "exclude": [
      "node_modules/**"
    ]
  },
  "rules": {
    "one-variable-per-declaration": false,
    "no-increment-decrement": false,
    "brace-style": [
      false,
      "stroustrup",
      {
        "allowSingleLine": true
      }
    ],
    "trailing-comma": [
      true,
      {
        "multiline": {
          "objects": "never",
          "arrays": "never",
          "functions": "never",
          "typeLiterals": "ignore"
        },
        "esSpecCompliant": true
      }
    ],
    "function-name": [
      true,
      {
        "function-regex": "^[a-zA-Z$][\\w\\d]+$",
        "method-regex": "^[a-z$][\\w\\d]+$",
        "private-method-regex": "^[a-z$][\\w\\d]+$",
        "protected-method-regex": "^[a-z$][\\w\\d]+$",
        "static-method-regex": "^[a-z$][\\w\\d]+$"
      }
    ],
    "max-line-length": [
      true,
      160
    ]
  }
}

Add lint script to package.json's scripts section:
"lint": "tslint --project tsconfig.json **/*.ts",

and test with npm run lint

With the default scaffold you should see the following:

ERROR: /Users/ivanbreet/Sites/simply-test/functions/hello.ts:8:19 - Unnecessary trailing comma
ERROR: /Users/ivanbreet/Sites/simply-test/functions/hello.ts:9:7 - Unnecessary trailing comma
ERROR: /Users/ivanbreet/Sites/simply-test/functions/hello.ts:11:2 - Missing semicolon

Quickly fix the above listing issues by replacing hello.ts with:

import { APIGatewayProxyHandler } from 'aws-lambda';

export const handler: APIGatewayProxyHandler = async (event, context) => {
  return{
    statusCode: 200,
    body: JSON.stringify({
      message: 'Go Serverless Webpack (Typescript) v1.0! Your function executed successfully!',
      input: event
    })
  };
};

Node 10+ compatibility

We want to take advantage of the native support for promises, async/await, generators and more. Instead of using a polyfill, change the TypeScript compiler output to match the environment.

Replace the existing tsconfig.json code with the following:

{
  "compilerOptions": {
    "alwaysStrict": true,
    "lib": [
      "es2017"
    ],
    "module": "commonjs",
    "moduleResolution": "node",
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "outDir": "lib",
    "removeComments": true,
    "sourceMap": true,
    "target": "es2017"
  },
  "exclude": [
    "node_modules"
  ]
}

Jest unit testing

Testing is crucial for ensuring code quality. This is especially true when working when looking at code reuse, code libraries and team collaboration. Jest makes unit tests fast and easy.

npm install --save-dev jest @types/jest ts-jest

Add jest.config.js with the following code:

module.exports = {
  testEnvironment: 'node',
  moduleFileExtensions: [
    'ts',
    'tsx',
    'js'
  ],
  transform: {
    '^.+\\.(ts|tsx)$': 'ts-jest'
  },
  testMatch: [
    '**/?(*.)(spec|test).(ts|tsx|js)?(x)'
  ]
};

Add your test scripts to package.json:

"scripts": {
  "test": "jest --watchAll --colors --coverage",
  "test:ci": "jest --ci --colors --coverage --passWithNoTests",
  "lint": "tslint --project tsconfig.json  **/*.ts",
  "local:hello": "serverless invoke local --function hello --path events/hello.json"
},

Logs

Going to the AWS lambda console logs suck. It's usually a slow, not so user-friendly and all round clunky process. Did you know you can quickly check your deployed AWS Lambda logs right from your project's command line?

It's easy, just add the following package.json script:

"tail:hello:dev": "serverless logs --function hello --tail --stage dev --aws-profile <your profile>"

Deploy individually

Quickly want to deploy just a single function?

Just add the following package.json script:

"deploy:hello": "serverless deploy function -f hello --aws-profile <your profile>"

Sourcemap support

Adding sourcemap support is something that one tends to forget about, only to realise what you have missed after a gremlin or two pops up in your code.

  1. Install sourcemap support:

npm install --save-dev source-map-support

2. In a new source-map-install.js file in the project root, add:

require('source-map-support').install();

3. And finally, in webpack.config.js add the following:

// webpack.config.js

const entries = {};
Object.keys(slsw.lib.entries).forEach(
  key => (entries[key] = ['./source-map-install.js', slsw.lib.entries[key]])
);

module.exports = {
  // ...
  entry: entries,
  // ...

Bonus tips

Exclude AWS-SDK

Ensure you add aws-sdk as a development dependency when it's required and exclude it in the build phase with:

// webpack.config.js

module.exports = {
  // ...
  externals: ['aws-sdk'],
  // ...

This will help to keep your package sizes down and make the overall builds faster as the aws-sdk is already included at runtime for all lambdas.

Consolidate S3 buckets

The AWS S3 100 might sound like it's sufficient. However, when you start creating your various functions with their respective stages, you can quickly end up hitting the 100 bucket limit. To be safe, create your staged deployment buckets and re-use them as needed. In your serverless.yml file add the following:

provider:
	// ...

	deploymentBucket:
    	name: test-serverless-deploy-${opt:stage, self:provider.stage}-${self:provider.region}
    	serverSideEncryption: AES256

Conclusion

We hope that you found the step by step approach helpful. We are going to expand on this in future articles by covering some CI/CD best practises, AWS profiling using the least privilege principle, middleware and advanced tooling in future posts.

Feel free to leave any comments, question or suggestions in the comment section below.