Looking for Senior AWS Serverless Architects & Engineers?
Let's TalkIn this article, we are going to talk about how to structure a monorepo serverless project with the Serverless Framework.
Ideal — Project Structure
Below you will see a high level view of what a serverless monorepo project may look like when working with the Serverless Framework.
lib/ # <-- shared service code
response.js # <-- adds CORS headers, success/failure methods
resources/ # <-- shared AWS resources
api/ # <-- creates shell api, attaches auth
db/ # <-- creates an aurora serverless database
vpc/ # <-- creates an AWS VPC for private/public subnets
services/ # <-- logical groupings of functionality
billing/ # <-- arbitrary service
src/ # <-- organizes business logic for cloud function
handlers/ # <-- lambda handlers
invoices/
index.js
collections/
index.js
serverless.yml # <-- reuses shell api
Let’s break down the top-level directories:
- lib — shared service code
- resources — shared AWS resources
- services— logical groupings of functionality
Lib — shared service code
Inside of our lib directory we are going to have some shared code which each individual service will have the ability to pull in.
lib/ # <-- shared service code
response.js # <-- adds CORS headers, success/failure methods
In the example above we have a file called response.js . This file will handle the response methods for our AWS Lambda functions.
// lib/response.jsconst response = {};
let response = {};
response.success = ({ body }) => {
return {
body: JSON.stringify(body),
statusCode: 200,
headers: {
"Access-Control-Allow-Origin": "*"
}
}
};
response.failure = ({ error }) => {
return {
body: JSON.stringify(error),
statusCode: 500,
headers: {
"Access-Control-Allow-Origin": "*"
}
}
};
module.exports = response;
Above you can see above we are simply adding a body , statusCode , and headers to our AWS Lambda response. This will now lower future work for all developers writing AWS Lambda functions and prevent issues with CORS, which is one of the most common problems developers will initially hit when developing serverless REST APIs.
resources — shared AWS resources
Inside of our resources/ directory we are going to have additional serverless stacks which will be reused across all of our services.
resources/ # <-- shared AWS resources
api/ # <-- creates shell api, attaches auth
db/ # <-- creates an aurora serverless database
vpc/ # <-- creates an AWS VPC for private/public subnets
If we expanded these subfolders we would see a structure like this.
resources/
api/
serverless.yml
db/
serverless.yml
vpc/
serverless.yml
Each one of these shared AWS resources stacks should also be outputting the necessary values to be consumed by the downstream services. An example, the api stack should output the REST API Id and the REST API Root Resource Id. Both are needed for services to reuse a singular AWS API Gateway REST API endpoint.
Here is what this example output section would look like if you were using Serverless Framework Pro (which we highly recommend at Serverless Guru)
org: serverlessguru # <-- swap out for your org
app: my-app # <-- swap out for your app
service: api
provider:
...
outputs:
ApiId:
Ref: ApiGatewayRestApi
ApiResourceId:
Fn::GetAtt:
- ApiGatewayRestApi
- RootResourceId
Now in my service I’ll be able to add this section which can make use of this section.
org: serverlessguru # <-- swap out for your org
app: my-app # <-- swap out for your app
service: ...
provider:
...
apiGateway:
restApiId: ${output:${self:custom.n}.ApiId}
restApiRootResourceId: ${output:${self:custom.n}.ApiResourceId}
custom:
n: my-app:${self:provider.stage}:${self:provider.region}:api
Note: I’ve made the variable custom.n named in this way to try and fit the code snippet in cleanly on Medium. These dynamic variable names can get quite long.
services — logical groupings of functionality
Inside our services/ directory is where the magic takes place or at least the majority of our application development.
services/ # <-- logical groupings of functionality
billing/ # <-- arbitrary service
src/ # <-- organizes business logic for cloud function
handlers/ # <-- lambda handlers
invoices/
index.js
collections/
index.js
serverless.yml # <-- reuses shell api
Above you can see we have one service called billing . Inside this service we have two AWS Lambda functions that will be packaged individually invoices and collections .
Inside our serverless.yml file we can see how this might work.
org: serverlessguru # <-- swap out for your org
app: my-app # <-- swap out for your app
service: serviceA
provider:
stage: ${opt:stage, "dev"}
region: ${opt:region, "us-west-2"}
apiGateway:
restApiId: ${output:${self:custom.n}.ApiId}
restApiRootResourceId: ${output:${self:custom.n}.ApiResourceId}
custom:
n: my-app:${self:provider.stage}:${self:provider.region}:api
base: ${self:service}-${self:provider.stage}
# Package the lambda functions individually by subfolder
package:
individually: true
exclude:
- ./**
functions:
invoices:
name: ${self:custom.base}-invoices
handler: index.handler
events:
- http:
path: /invoices
method: ANY
cors: true
package:
include:
- src/handlers/invoices/**
- ../lib/**
collections:
name: ${self:custom.base}-collections
handler: index.handler
events:
- http:
path: /collections
method: ANY
cors: true
package:
include:
- src/handlers/collections/**
- ../lib/**
Above we are doing all the setup required to package our AWS Lambda functions individually. However, we are also doing the following:
- Setting up CORS
- Passing all paths/methods matching /collections or /invoices to the relevant AWS Lambda function while keeping some isolation
- Using a consistent naming convention