The Blog

Our latest insights, opinions and general musings as we float about on the vast ocean of tech we call home.

Sharing common Serverless Framework code & config

Sharing common Serverless Framework code & config

When you build a project with the Serverless Framework it's quite likely that you get started with a single service that contains all of your Lambda functions and other resources. Over time that service grows to effectively become a "serverless monolith" which becomes messy and leads to problems like hitting the CloudFormation resource limit.

In the interest of following the software engineering design principle of separation of concerns, you may want to consider splitting your monolithic service into smaller, encapsulated microservices. The Serverless Framework is designed to support this use case (arguably, it's the architecture it expects by default) but there's a key limitation that will soon become a frustration - it's difficult to effectively share Serverless Framework configuration.

In this article we'll explore a multi-service monorepo setup that overcomes that limitation.

Multiple services in one repository

A "service" in the context of the Serverless Framework is a collection of Lambda functions and cloud platform resources defined by a serverless.yml file. There is nothing stopping you having many of those files in one repository. The directory structure that we use at Orange Jellyfish looks something like this:

└── services/
    ├── hello-service/
    │   └── src/
    │       ├── functions/
    │       │   └── hello/
    │       │       ├── index.js
    │       │       └── index.yml
    │       ├── resources/
    │       │   └── s3.yml
    │       ├── package.json
    │       └── serverless.yml
    └── goodbye-service/
        └── src/
            ├── functions/
            │   └── goodbye/
            │       ├── index.js
            │       └── index.yml
            ├── package.json
            └── serverless.yml

With this structure each service is effectively a standalone Serverless Framework application and can be deployed independently. Each service defines its own dependencies in its own package.json file. The structure within each service directory can vary but consistency is important and we follow the structure and conventions set by our Serverless starter kit in all of them.

Sharing code between services

It's likely that many of your services will depend on the same code. For example, you might have a set of models, or some database utilities. If such code cannot be split out into standalone packages and pulled in via npm we need a place in the monorepo for them. A good approach is to add a top-level directory alongside services:

your-app/
├── services/
│   ├── hello-service/
│   └── goodbye-service/
└── common/
    └── s3-utils.js

This structure requires a change to the serverless.yml files of the services. By default, the Serverless Framework treats the directory in which serverless.yml resides as the boundary of the service and will not allow files outside of that directory tree to be imported. You can change this with the projectDir configuration option. Set it to the top-level of the monorepo, relative to the service directory itself, to allow imports from anywhere in that tree:

service: hello-service
projectDir: '../../'

If you're bundling your Lambda function code with Webpack (if you use our starter kit this will already be the case) you can set up an alias to tidy up the long messy relative import paths. For the common directory shown above that would require the following change to webpack.config.js:

const path = require('path');

module.exports = {
  resolve: {
    alias: {
      '~common': path.resolve(__dirname, 'common'),
    },
  },
};

In your Lambda function code you can then replace those long paths with the alias:

import s3 from '~common/s3-utils';

Sharing configuration between services

We're now at a point where we've removed the need to duplicate code between services but it's very likely there's still a lot of common configuration in all those serverless.yml files. A good example is a list of Serverless Framework plugins used by all of the services, because you probably want to use serverless-webpack in every case.

We can define common configuration files, and install common dependencies, in the root of our monorepo:

your-app
  services/
  common/
  package.json
  serverless-plugins.yml

We can then import those files with the Serverless Framework file variable syntax in the individual serverless.yml files of our services:

# your-app/serverless-plugins.yml
- serverless-webpack
# your-app/services/hello-service/serverless.yml
service: hello-service
projectDir: '../../'

plugins: ${file(../../serverless-plugins.yml)}

This works well enough for most of the properties supported by serverless.yml but it falls down if you try to share some provider configuration, which is a shame because it's almost a certainty that each service is deployed to the same cloud provider and region. The Serverless Framework does not provide a hook that runs early enough to set those properties in a way that would allow us to share them across services.

Using serverless-config-merge

To solve this specific problem at Orange Jellyfish we developed the serverless-merge-config tool, a command-line utility to merge Serverless Framework config files prior to deployment. You can install the utility in the root of your monorepo and then use a syntax inspired by the YAML merge key proposal to define parts of your configuration that should be merged before deployment:

# your-app/serverless-provider-defaults.yml
name: aws
region: eu-west-1
# your-app/services/hello-service/serverless.yml
service: hello-service
projectDir: '../../'

provider:
  $<<: ${file(../../serverless-provider-defaults.yml)}

The final step is to write a deployment script to run serverless-merge-config before running serverless deploy:

#!/bin/sh

OUT_FILE=serverless-merged.json

cd services/"$npm_config_service" || exit
sls-config-merge -o $OUT_FILE
sls deploy --config $OUT_FILE --stage="$SLS_STAGE"
rm $OUT_FILE

Add this as a script in your top-level package.json file:

{
  "scripts": {
    "deploy:env:service": "./scripts/deploy-env-service",
  }
}

And finally you can deploy individual services with the command as follows:

npm run deploy:env:service --service=hello-service

Want to know more?

Get in touch.