├── documentation
├── testing.png
├── high-level.png
├── testing-e2e.png
├── testing-int.png
└── diagrams.drawio
├── config
├── unit.jest.config.js
├── stage.cjs
├── integration.jest.config.mjs
├── e2e.jest.config.mjs
└── deployment.yml
├── jsconfig.json
├── .gitignore
├── src
├── createItem
│ ├── schema.responseSchema.json
│ ├── schema.eventSchema.json
│ ├── businessLogic.mjs
│ └── function.mjs
├── processItem
│ └── function.mjs
└── common
│ ├── entities
│ └── MyEntity.mjs
│ ├── services
│ └── MyEntityService.mjs
│ └── adapters
│ └── DynamoDbAdapter.mjs
├── __tests__
├── common
│ ├── services
│ │ └── MyEntityService.int.mjs
│ ├── entities
│ │ └── MyEntity.test.mjs
│ └── adapters
│ │ └── DynamodbAdapter.int.mjs
├── createItem
│ ├── businessLogic.test.mjs
│ ├── function.int.mjs
│ └── iam-createItem-MyEntityService.int.mjs
└── processItem
│ ├── functionIsTriggeredByDdbStream.e2e.mjs
│ └── iam-processItem-MyEntityService.int.mjs
├── iac
├── dynamodb.yml
└── functions.yml
├── LICENSE
├── eslint.config.mjs
├── serverless.yml
├── package.json
└── README.md
/documentation/testing.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/serverlesspolska/serverless-hexagonal-template/HEAD/documentation/testing.png
--------------------------------------------------------------------------------
/documentation/high-level.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/serverlesspolska/serverless-hexagonal-template/HEAD/documentation/high-level.png
--------------------------------------------------------------------------------
/documentation/testing-e2e.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/serverlesspolska/serverless-hexagonal-template/HEAD/documentation/testing-e2e.png
--------------------------------------------------------------------------------
/documentation/testing-int.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/serverlesspolska/serverless-hexagonal-template/HEAD/documentation/testing-int.png
--------------------------------------------------------------------------------
/config/unit.jest.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | testEnvironment: 'node',
3 | roots: ['../'],
4 | testMatch: ['**/*.test.js', '**/*.test.mjs']
5 | }
6 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "ES2022",
4 | "target": "ES2022"
5 | },
6 | "include": [
7 | "src/**/*",
8 | "__tests__/**/*"
9 | ],
10 | "exclude": [
11 | "node_modules"
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/config/stage.cjs:
--------------------------------------------------------------------------------
1 | const os = require('os');
2 |
3 | const stage = () => {
4 | console.log(`Stage not provided. Using "local" stage name based on username: 'dev-${os.userInfo().username}'.`);
5 | return `dev-${os.userInfo().username}`
6 | }
7 |
8 | module.exports.userStage = stage
9 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # package directories
2 | node_modules
3 | jspm_packages
4 |
5 | # Serverless directories
6 | .serverless
7 |
8 | # local config files
9 | .envrc
10 | .env
11 | .nvmrc
12 | .awsenv
13 |
14 | # IDE
15 | .vscode
16 |
17 | # trash
18 | .DS_store
19 | .DS_Store
20 | template.drawio.bak
21 |
22 | # schemas
23 | schema.*.mjs
24 |
25 | # build
26 | dist
27 |
--------------------------------------------------------------------------------
/src/createItem/schema.responseSchema.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "Response Schema",
3 | "type": "object",
4 | "properties": {
5 | "id": {
6 | "type": "string"
7 | },
8 | "result": {
9 | "type": "number"
10 | },
11 | "createdAt": {
12 | "type": "string",
13 | "format": "date-time"
14 | }
15 | },
16 | "required": [
17 | "id",
18 | "result",
19 | "createdAt"
20 | ]
21 | }
22 |
--------------------------------------------------------------------------------
/config/integration.jest.config.mjs:
--------------------------------------------------------------------------------
1 | import * as dotenv from 'dotenv';
2 |
3 | export default {
4 | testEnvironment: 'node',
5 | roots: ['../__tests__/'],
6 | testMatch: ['**/*.(int|integration).mjs'],
7 | testTimeout: 60000 * 2, // 2 minutes timeout
8 | }
9 |
10 | // Load environment variables generated by serverless-export-env plugin
11 | dotenv.config({
12 | path: '.awsenv',
13 | bail: 1,
14 | testEnvironment: 'node'
15 | })
16 |
--------------------------------------------------------------------------------
/config/e2e.jest.config.mjs:
--------------------------------------------------------------------------------
1 | import * as dotenv from 'dotenv';
2 |
3 | export default {
4 | testEnvironment: 'node',
5 | roots: ['../__tests__/'],
6 | testMatch: ['**/*.(e2e).mjs'],
7 | testTimeout: 60000 * 7, // 7 minutes timeout
8 | // Enable this if you're using aws-testing-library
9 | setupFilesAfterEnv: ['../node_modules/aws-testing-library/lib/jest/index.js'],
10 | }
11 |
12 | // Load environment variables generated by serverless-export-env plugin
13 | dotenv.config({
14 | path: '.awsenv',
15 | bail: 1
16 | })
17 |
--------------------------------------------------------------------------------
/__tests__/common/services/MyEntityService.int.mjs:
--------------------------------------------------------------------------------
1 | import { MyEntityService } from '../../../src/common/services/MyEntityService.mjs'
2 |
3 | describe('MyEntity service', () => {
4 | it('should create and save new entity', async () => {
5 | // GIVEN
6 | const result = 48
7 | const service = new MyEntityService()
8 |
9 | // WHEN
10 | const actual = await service.create(result)
11 | const itemStoredInDb = await service.getById(actual.id)
12 |
13 | // THEN
14 | expect(itemStoredInDb).toBeTruthy()
15 | expect(actual).toEqual(itemStoredInDb)
16 | })
17 | })
18 |
--------------------------------------------------------------------------------
/src/createItem/schema.eventSchema.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "Event Schema",
3 | "type": "object",
4 | "properties": {
5 | "body": {
6 | "type": "object",
7 | "properties": {
8 | "a": {
9 | "type": "number"
10 | },
11 | "b": {
12 | "type": "number"
13 | },
14 | "method": {
15 | "type": "string"
16 | }
17 | },
18 | "required": [
19 | "a",
20 | "b",
21 | "method"
22 | ]
23 | }
24 | },
25 | "required": [
26 | "body"
27 | ]
28 | }
29 |
--------------------------------------------------------------------------------
/iac/dynamodb.yml:
--------------------------------------------------------------------------------
1 | Resources:
2 | Table:
3 | Type: AWS::DynamoDB::Table
4 | Properties:
5 | AttributeDefinitions:
6 | - AttributeName: PK
7 | AttributeType: S
8 | KeySchema:
9 | - AttributeName: PK
10 | KeyType: HASH
11 | BillingMode: PAY_PER_REQUEST
12 | TableName: ${self:custom.tableName}
13 | Tags:
14 | - Key: Application
15 | Value: ${self:service}
16 | - Key: Stage
17 | Value: ${self:provider.stage}
18 | - Key: StackName
19 | Value: !Ref AWS::StackId
20 | StreamSpecification:
21 | StreamViewType: NEW_IMAGE
--------------------------------------------------------------------------------
/src/processItem/function.mjs:
--------------------------------------------------------------------------------
1 | import { Logger } from '@aws-lambda-powertools/logger'
2 |
3 | const logger = new Logger({ serviceName: import.meta.url.split('/').pop() });
4 |
5 | export const handler = async (event) => {
6 | const item = parseEvent(event)
7 | logger.info('Received item to process', { item })
8 |
9 | // this log message is used for testing
10 | // don't remove it. See: functionIsTriggeredByDdbStream.e2e.js
11 | logger.info(`Processing item ${item.dynamodb.Keys.PK.S}`)
12 |
13 | // here you can implement rest of your Lambda code
14 |
15 | return true
16 | }
17 |
18 | const parseEvent = (event) => event.Records[0]
19 |
--------------------------------------------------------------------------------
/src/createItem/businessLogic.mjs:
--------------------------------------------------------------------------------
1 | import { Logger } from '@aws-lambda-powertools/logger'
2 |
3 | const logger = new Logger({ serviceName: import.meta.url.split('/').pop() });
4 |
5 | export const performCalculation = ({ a, b, method }) => {
6 | logger.info('Received method with values', {
7 | method,
8 | values: {
9 | a,
10 | b,
11 | }
12 | })
13 | switch (method) {
14 | case 'add':
15 | return a + b
16 | // To Do - implement other methods (just sample, not a real to do)
17 | default:
18 | throw new NotImplementedYetError()
19 | }
20 | }
21 |
22 | class NotImplementedYetError extends Error {
23 | constructor() {
24 | super()
25 | this.status = 400
26 | this.statusCode = 400
27 | this.name = 'NotImplementedYetError'
28 | this.message = 'Not implemented yet!'
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/__tests__/createItem/businessLogic.test.mjs:
--------------------------------------------------------------------------------
1 | import { performCalculation } from '../../src/createItem/businessLogic.mjs'
2 |
3 | describe('Creat Item buiness logic suite', () => {
4 | it('should add numbers', () => {
5 | // GIVEN
6 | const a = 2
7 | const b = 5
8 | const method = 'add'
9 |
10 | // WHEN
11 | const actual = performCalculation({ a, b, method })
12 |
13 | // THEN
14 | expect(actual).toBe(7)
15 | })
16 |
17 | it('should throw error on bad method', () => {
18 | // GIVEN
19 | const a = 2
20 | const b = 5
21 | const method = 'divide'
22 |
23 | // WHEN
24 | let error
25 | try {
26 | performCalculation({ a, b, method })
27 | } catch (e) {
28 | error = e
29 | }
30 |
31 | // THEN
32 | expect(error.message).toBe('Not implemented yet!')
33 | expect(error.status).toBe(400)
34 | })
35 | })
36 |
--------------------------------------------------------------------------------
/iac/functions.yml:
--------------------------------------------------------------------------------
1 | createItem:
2 | handler: src/createItem/function.handler
3 | description: Create Item in repository
4 | memorySize: 128
5 | timeout: 5
6 | environment:
7 | tableName: ${self:custom.tableName}
8 | events:
9 | - httpApi:
10 | method: POST
11 | path: /item
12 | iamRoleStatements:
13 | - Sid: DynamoDBReadWrite
14 | Effect: Allow
15 | Action:
16 | - dynamodb:PutItem
17 | - dynamodb:UpdateItem
18 | Resource:
19 | - !GetAtt Table.Arn
20 |
21 | processItem:
22 | handler: src/processItem/function.handler
23 | description: Triggered by DynamoDB Streams. Does some work on newly created Item
24 | memorySize: 128
25 | timeout: 5
26 | environment:
27 | message: Hello World!
28 | events:
29 | - stream:
30 | type: dynamodb
31 | arn: !GetAtt Table.StreamArn
32 | maximumRetryAttempts: 1
33 | batchSize: 1
34 | iamRoleStatements:
35 | - Sid: DynamoDBRead
36 | Effect: Allow
37 | Action:
38 | - dynamodb:GetItem
39 | Resource:
40 | - !GetAtt Table.Arn
--------------------------------------------------------------------------------
/src/createItem/function.mjs:
--------------------------------------------------------------------------------
1 | import middy from '@middy/core'
2 | import jsonBodyParser from '@middy/http-json-body-parser'
3 | import httpErrorHandler from '@middy/http-error-handler'
4 | import validator from 'middy-ajv'
5 | import { Logger } from '@aws-lambda-powertools/logger'
6 |
7 | import { performCalculation } from './businessLogic.mjs'
8 | import { MyEntityService } from '../common/services/MyEntityService.mjs'
9 | import eventSchema from './schema.eventSchema.mjs'
10 | import responseSchema from './schema.responseSchema.mjs'
11 |
12 | const logger = new Logger({ serviceName: import.meta.url.split('/').pop() });
13 |
14 | const lambdaHandler = async (event) => {
15 | logger.info('Starting Lambda function')
16 | const result = performCalculation(event.body)
17 | const myEntityService = new MyEntityService()
18 | return (await myEntityService.create(result)).toDto()
19 | }
20 |
21 | export const handler = middy()
22 | .use(jsonBodyParser())
23 | .use(validator({ eventSchema, responseSchema }))
24 | .use(httpErrorHandler({ logger: (...args) => logger.error(args) }))
25 | .handler(lambdaHandler)
26 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Paweł Zubkiewicz
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/common/entities/MyEntity.mjs:
--------------------------------------------------------------------------------
1 | import { randomBytes } from 'node:crypto';
2 | import KSUID from 'ksuid';
3 |
4 | export class MyEntity {
5 | constructor({ id, result, createdAt = new Date() }) {
6 | this.createdAt = createdAt instanceof Date ? createdAt : new Date(createdAt)
7 | this.result = parseInt(result, 10)
8 | this.id = id || this.generateId(createdAt)
9 | }
10 |
11 | key() {
12 | return {
13 | PK: { S: this.id }
14 | }
15 | }
16 |
17 | static fromItem(item) {
18 | return new MyEntity({
19 | id: item.PK.S,
20 | result: item.result.N,
21 | createdAt: item.createdAt.S
22 | })
23 | }
24 |
25 | toItem() {
26 | return {
27 | ...this.key(),
28 | result: { N: this.result.toString() },
29 | createdAt: { S: this.createdAt.toISOString() },
30 | }
31 | }
32 |
33 | generateId(createdAt) {
34 | const payload = randomBytes(16)
35 | return KSUID.fromParts(createdAt.getTime(), payload).string
36 | }
37 |
38 | toDto() {
39 | return {
40 | id: this.id,
41 | result: this.result,
42 | createdAt: this.createdAt.toISOString()
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/config/deployment.yml:
--------------------------------------------------------------------------------
1 | deployment:
2 | region:
3 | dev: eu-central-1 # Frankfurt
4 | test: eu-west-1 # Ireland
5 | prod: us-east-1 # N.Virginia
6 | globalStages: # need for per-developer stages
7 | dev: dev
8 | test: test
9 | prod: prod
10 |
11 | # If you need to run Lambda inside VPC uncomment below
12 | # and configure SG and subnets below:
13 | #
14 | # vpc:
15 | # dev:
16 | # securityGroupId: sg-abcdefgh
17 | # subnetId1: subnet-12345678
18 | # subnetId2: subnet-12345678
19 | # test:
20 | # securityGroupId: sg-abcdefgh
21 | # subnetId1: subnet-12345678
22 | # subnetId2: subnet-12345678
23 | # prod:
24 | # securityGroupId: sg-abcdefgh
25 | # subnetId1: subnet-12345678
26 | # subnetId2: subnet-12345678
27 | #
28 | #
29 | # Next in serverless.yml in Lambda function section add following:
30 | # vpc:
31 | # securityGroupIds:
32 | # - ${self:custom.deployment.vpc.${self:provider.stage}.securityGroupId}
33 | # subnetIds:
34 | # - ${self:custom.deployment.vpc.${self:provider.stage}.subnetId1}
35 | # - ${self:custom.deployment.vpc.${self:provider.stage}.subnetId2}
--------------------------------------------------------------------------------
/__tests__/common/entities/MyEntity.test.mjs:
--------------------------------------------------------------------------------
1 | import { MyEntity } from '../../../src/common/entities/MyEntity.mjs'
2 |
3 | describe('My Entity', () => {
4 | it('should be created from parameters', () => {
5 | // GIVEN
6 | const params = {
7 | result: 48
8 | }
9 |
10 | // WHEN
11 | const actual = new MyEntity(params)
12 |
13 | // THEN
14 | expect(actual.id).toBeTruthy()
15 | expect(actual.createdAt).toBeTruthy()
16 | expect(actual.result).toBe(48)
17 | })
18 |
19 | it('should be transformed to DynamoDB structure', () => {
20 | // GIVEN
21 | const params = {
22 | result: 48
23 | }
24 |
25 | // WHEN
26 | const item = new MyEntity(params)
27 | const actual = item.toItem()
28 |
29 | // THEN
30 | expect(actual.PK.S).toBeTruthy()
31 | expect(actual.createdAt.S).toBeTruthy()
32 | expect(actual.result.N).toBe('48')
33 | })
34 |
35 | it('should be from DynamoDB item to object', () => {
36 | // GIVEN
37 | const params = {
38 | result: 48
39 | }
40 | const item = new MyEntity(params)
41 | const dbItem = item.toItem()
42 |
43 | // WHEN
44 | const actual = MyEntity.fromItem(dbItem)
45 |
46 | // THEN
47 | expect(actual.id).toBe(dbItem.PK.S)
48 | expect(actual.createdAt.toISOString()).toBe(dbItem.createdAt.S)
49 | expect(actual.result).toBe(params.result)
50 | })
51 | })
52 |
--------------------------------------------------------------------------------
/__tests__/processItem/functionIsTriggeredByDdbStream.e2e.mjs:
--------------------------------------------------------------------------------
1 | const baseURL = `https://${process.env.httpApiGatewayEndpointId}.execute-api.${process.env.region}.amazonaws.com`
2 |
3 | describe('processItem Lambda function', () => {
4 | it('should be invoked by DDB Stream after createItem Lambda saves element into DynamoDB', async () => {
5 | // GIVEN
6 | const payload = {
7 | a: 10,
8 | b: 5,
9 | method: 'add'
10 | }
11 |
12 | // WHEN
13 | const response = await fetch(`${baseURL}/item`, {
14 | method: 'POST',
15 | body: JSON.stringify(payload),
16 | headers: {
17 | 'Content-Type': 'application/json',
18 | }
19 | })
20 | const actual = await response.json()
21 | const newItemDbId = actual.id
22 |
23 | // THEN
24 | expect(response.status).toBe(200)
25 |
26 | // Using aws-testing-library lib that extends jest framework
27 | await expect({
28 | region: process.env.region,
29 | function: `${process.env.service}-${process.env.stage}-processItem`,
30 | timeout: 25 * 1000
31 | }).toHaveLog(
32 | /*
33 | A log message in the processItem Lambda function containing the ID
34 | of the newly created item confirms that the function was successfully
35 | invoked and that the DynamoDB Stream integration is correctly configured.
36 | */
37 | `Processing item ${newItemDbId}`
38 | );
39 | })
40 | })
41 |
--------------------------------------------------------------------------------
/src/common/services/MyEntityService.mjs:
--------------------------------------------------------------------------------
1 | import { Logger } from '@aws-lambda-powertools/logger'
2 |
3 | import { DynamoDbAdapter } from '../adapters/DynamoDbAdapter.mjs';
4 | import { MyEntity } from '../entities/MyEntity.mjs'
5 |
6 | const logger = new Logger({ serviceName: import.meta.url.split('/').pop() });
7 |
8 | export class MyEntityService {
9 | constructor(dynamoDbAdapter) {
10 | this.dynamoDbAdapter = dynamoDbAdapter || new DynamoDbAdapter()
11 | this.tableName = process.env.tableName
12 | }
13 |
14 | async create(result) {
15 | logger.info('Creating MyEntity item in repository')
16 | const myEntity = new MyEntity({ result })
17 | await this.dynamoDbAdapter.createItem(this.tableName, myEntity)
18 | return myEntity
19 | }
20 |
21 | async getById(id) {
22 | const paramsGet = {
23 | Key: {
24 | PK: { S: id }
25 | },
26 | ReturnConsumedCapacity: 'TOTAL',
27 | TableName: this.tableName
28 | }
29 | const response = await this.dynamoDbAdapter.get(paramsGet)
30 | const item = response.Item
31 |
32 | if (item) {
33 | return new MyEntity({ id: item.PK.S, result: item.result.N, createdAt: item.createdAt.S });
34 | }
35 | return item;
36 | }
37 |
38 | async getByResult(value) {
39 | const response = await this.dynamoDbAdapter.queryByField(this.tableName, 'result', value)
40 |
41 | return response.Items.map((item) => new MyEntity({ id: item.PK, result: item.result, createdAt: item.createdAt }))
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/__tests__/processItem/iam-processItem-MyEntityService.int.mjs:
--------------------------------------------------------------------------------
1 | import IamTestHelper from 'serverless-iam-test-helper';
2 | import { MyEntityService } from '../../src/common/services/MyEntityService.mjs';
3 |
4 | IamTestHelper.describeWithRole('ProcessItem Lambda IAM Role', 'processItem', () => {
5 | it('should ALLOW dynamodb:GetItem', async () => {
6 | // GIVEN
7 | const service = new MyEntityService()
8 |
9 | // WHEN
10 | await service.getById('any id')
11 |
12 | // THEN
13 | // lack of exception means that GetItem action is allowed
14 | // TODO improve that test there is explicit assertion
15 | })
16 |
17 | it('should DENY dynamodb:PutItem', async () => {
18 | // GIVEN
19 | const result = 48
20 | const service = new MyEntityService()
21 |
22 | // WHEN
23 | let exception
24 | try {
25 | await service.create(result)
26 | } catch (error) {
27 | exception = error
28 | }
29 |
30 | // THEN
31 | expect(exception.name).toBe('AccessDeniedException')
32 | expect(exception.message.includes('is not authorized to perform: dynamodb:PutItem')).toBeTruthy()
33 | })
34 | it('should DENY dynamodb:Query', async () => {
35 | // GIVEN
36 | const result = 48
37 | const service = new MyEntityService()
38 |
39 | // WHEN
40 | let exception
41 | try {
42 | await service.getByResult(result)
43 | } catch (error) {
44 | exception = error
45 | }
46 |
47 | // THEN
48 | expect(exception.name).toBe('AccessDeniedException')
49 | expect(exception.message.includes('is not authorized to perform: dynamodb:Query')).toBeTruthy()
50 | })
51 | })
52 |
--------------------------------------------------------------------------------
/__tests__/createItem/function.int.mjs:
--------------------------------------------------------------------------------
1 | const baseURL = `https://${process.env.httpApiGatewayEndpointId}.execute-api.${process.env.region}.amazonaws.com`
2 |
3 | describe('createItem function', () => {
4 | it('should respond with statusCode 200 to correct request', async () => {
5 | // GIVEN
6 | const payload = {
7 | a: 10,
8 | b: 5,
9 | method: 'add'
10 | }
11 |
12 | // WHEN
13 | const response = await fetch(`${baseURL}/item`, {
14 | method: 'POST',
15 | body: JSON.stringify(payload),
16 | headers: {
17 | "Content-Type": "application/json",
18 | }
19 | })
20 |
21 | // THEN
22 | expect(response.status).toBe(200)
23 | })
24 |
25 | it('should respond with Bad Request 400 to incorrect request', async () => {
26 | // GIVEN
27 | const wrongPayload = {}
28 |
29 | // WHEN
30 | const response = await fetch(`${baseURL}/item`, {
31 | method: 'POST',
32 | body: JSON.stringify(wrongPayload),
33 | headers: {
34 | "Content-Type": "application/json",
35 | }
36 | })
37 |
38 | // THEN
39 | expect(response.status).toBe(400)
40 | })
41 |
42 | it('should respond with Not implemented yet for other methods than add', async () => {
43 | // GIVEN
44 | const payload = {
45 | a: 10,
46 | b: 5,
47 | method: 'divide'
48 | }
49 |
50 | // WHEN
51 | const response = await fetch(`${baseURL}/item`, {
52 | method: 'POST',
53 | body: JSON.stringify(payload),
54 | headers: {
55 | "Content-Type": "application/json",
56 | }
57 | })
58 | const text = await response.text()
59 |
60 | // THEN
61 | expect(response.status).toBe(400)
62 | expect(text).toEqual('Not implemented yet!')
63 | })
64 | })
65 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import neostandard from 'neostandard'
2 | import globals from 'globals'
3 |
4 | export default [
5 | ...neostandard({
6 | // Add Node.js and Jest globals
7 | globals: {
8 | ...globals.node,
9 | ...globals.jest
10 | },
11 |
12 | // Ignore patterns (similar to previous .eslintignore)
13 | ignores: [
14 | '**/node_modules/**',
15 | '**/dist/**',
16 | '**/.serverless/**',
17 | '**/.webpack/**',
18 | '**/*.json',
19 | '**/*.yml',
20 | '**/*.env',
21 | '**/*.md',
22 | '**/*.sh',
23 | '**/schema.*.mjs' // Auto-generated schema files
24 | ]
25 | }),
26 |
27 | // Additional rules to match previous configuration and existing code style
28 | {
29 | rules: {
30 | // Disable semi-colon enforcement (matches previous config)
31 | semi: 'off',
32 | '@stylistic/semi': 'off',
33 |
34 | // Allow console statements
35 | 'no-console': 'off',
36 |
37 | // Unused vars with underscore prefix ignored
38 | 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
39 |
40 | // NO space before function parentheses (matches existing code)
41 | 'space-before-function-paren': 'off',
42 | '@stylistic/space-before-function-paren': 'off',
43 |
44 | // Allow trailing commas (matches existing code)
45 | 'comma-dangle': 'off',
46 | '@stylistic/comma-dangle': 'off',
47 |
48 | // Line break style off (cross-platform compatibility)
49 | 'line-break-style': 'off',
50 | 'linebreak-style': 'off',
51 |
52 | // Import rules
53 | 'no-use-before-define': 'off',
54 | 'import/prefer-default-export': 'off',
55 | 'import/no-extraneous-dependencies': 'off',
56 | 'import/extensions': 'off'
57 | }
58 | }
59 | ]
60 |
--------------------------------------------------------------------------------
/serverless.yml:
--------------------------------------------------------------------------------
1 | service: serverless-hexagonal-template
2 |
3 | frameworkVersion: '^3'
4 |
5 | plugins:
6 | - serverless-better-credentials
7 | - serverless-iam-roles-per-function
8 | - serverless-export-env
9 | - serverless-plugin-scripts
10 |
11 | provider:
12 | name: aws
13 | stage: ${opt:stage, file(./config/stage.cjs):userStage}
14 | runtime: nodejs22.x
15 | region: ${opt:region, self:custom.deployment.region.${self:custom.globalStage}}
16 | logRetentionInDays: 60 # how long logs are kept in CloudWatch
17 | deploymentMethod: direct
18 | environment:
19 | # required Environment Variables. Don't remove.
20 | stage: ${self:provider.stage}
21 | region: ${self:provider.region}
22 | service: ${self:service}
23 | POWERTOOLS_SERVICE_NAME: ${self:service}
24 | # your variables - optional
25 | httpApiGatewayEndpointId: !Ref HttpApi
26 | tags:
27 | Application: ${self:service}
28 | Stage: ${self:provider.stage}
29 |
30 | configValidationMode: warn
31 |
32 | custom:
33 | deployment: ${file(config/deployment.yml):deployment}
34 | globalStage: ${self:custom.deployment.globalStages.${self:provider.stage}, 'dev'}
35 | description: Your short project description that will be shown in Lambda -> Applications console & in CloudFormation stack
36 | tableName: ${self:service}-${self:provider.stage}
37 | export-env: # serverless-export-env config
38 | filename: .awsenv # custom filename to avoid conflict with Serverless Framework '.env' auto loading feature
39 | overwrite: true
40 | scripts:
41 | hooks:
42 | 'before:package:createDeploymentArtifacts': npm run build
43 |
44 | functions: ${file(iac/functions.yml)}
45 |
46 | package:
47 | patterns:
48 | # exclude
49 | - '!__tests__/**'
50 | - '!documentation/**'
51 | - '!config/**'
52 | - '!iac/**'
53 | - '!src/**/schema.*.json'
54 | - '!*'
55 |
56 | resources:
57 | - Description: ${self:custom.description}
58 | # define each resource in a separate file per service in `iac` folder
59 | - ${file(iac/dynamodb.yml)}
60 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "serverless-hexagonal-template",
3 | "version": "1.0.0",
4 | "description": "Highly opinionated project template for Serverless Framework that follows and applies hexagonal architecture principle to serverless world. Prepared with easy testing in mind.",
5 | "author": "Pawel Zubkiewicz",
6 | "license": "MIT",
7 | "type": "module",
8 | "scripts": {
9 | "eslint": "node_modules/.bin/eslint src/**/*.mjs --ignore-pattern node_modules/",
10 | "export-env-dev": "STAGE=${STAGE:=dev} && sls export-env --all -s $STAGE",
11 | "export-env-local": "sls export-env --all",
12 | "test": "npm run build && node --experimental-vm-modules node_modules/jest/bin/jest.js --config config/unit.jest.config.js",
13 | "integration": "npm run export-env-local && node --experimental-vm-modules node_modules/jest/bin/jest.js --config config/integration.jest.config.mjs",
14 | "int": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config config/integration.jest.config.mjs",
15 | "e2e": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config config/e2e.jest.config.mjs",
16 | "all": "npm run test && npm run integration && npm run e2e",
17 | "all-dev": "npm run test && npm run export-env-dev && npm run int && npm run e2e",
18 | "build": "npm i && ./build.sh"
19 | },
20 | "devDependencies": {
21 | "@aws-sdk/client-dynamodb": "3.830.0",
22 | "@aws-sdk/lib-dynamodb": "3.830.0",
23 | "@eslint/js": "^9.29.0",
24 | "@types/jest": "30.0.0",
25 | "ajv": "7.2.4",
26 | "ajv-cmd": "0.7.12",
27 | "ajv-formats": "2.1.1",
28 | "aws-testing-library": "gerwant/aws-testing-library#modernization",
29 | "dotenv": "16.5.0",
30 | "eslint": "9.29.0",
31 | "eslint-plugin-import": "2.32.0",
32 | "globals": "^16.2.0",
33 | "jest": "30.0.2",
34 | "neostandard": "^0.12.1",
35 | "osls": "^3.51.1",
36 | "serverless-better-credentials": "2.0.1",
37 | "serverless-export-env": "2.2.0",
38 | "serverless-iam-roles-per-function": "3.2.0",
39 | "serverless-iam-test-helper": "1.1.0",
40 | "serverless-plugin-scripts": "1.0.2"
41 | },
42 | "dependencies": {
43 | "@aws-lambda-powertools/logger": "2.22.0",
44 | "@middy/core": "5.5.1",
45 | "@middy/http-error-handler": "5.5.1",
46 | "@middy/http-json-body-parser": "5.5.1",
47 | "ksuid": "3.0.0",
48 | "middy-ajv": "3.0.0"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/__tests__/createItem/iam-createItem-MyEntityService.int.mjs:
--------------------------------------------------------------------------------
1 | import IamTestHelper from 'serverless-iam-test-helper';
2 | import { MyEntityService } from '../../src/common/services/MyEntityService.mjs'
3 | import { DynamoDbAdapter } from '../../src/common/adapters/DynamoDbAdapter.mjs';
4 |
5 | const cleanup = []
6 |
7 | IamTestHelper.describeWithRole('CreateItem Lambda IAM Role', 'createItem', () => {
8 | it('should ALLOW dynamodb:PutItem', async () => {
9 | // GIVEN
10 | const result = 48
11 | const service = new MyEntityService()
12 |
13 | // WHEN
14 | const actual = await service.create(result)
15 |
16 | // THEN
17 | expect(actual).toBeTruthy()
18 | expect(actual.result).toBe(result)
19 | expect(actual.id.length).toBeGreaterThan(10)
20 |
21 | // expect actual.createdAt to be less than 1 minute old
22 | const now = new Date()
23 | const createdAt = new Date(actual.createdAt)
24 | expect(now.getTime() - createdAt.getTime()).toBeLessThan(60 * 1000)
25 |
26 | // CLEANUP
27 | cleanup.push(actual) // will be automatically cleaned up
28 | })
29 |
30 | it('should DENY dynamodb:GetItem', async () => {
31 | // GIVEN
32 | const service = new MyEntityService()
33 |
34 | // WHEN
35 | let exception
36 | try {
37 | await service.getById('any id')
38 | } catch (error) {
39 | exception = error
40 | }
41 |
42 | // THEN
43 | expect(exception.name).toBe('AccessDeniedException')
44 | expect(exception.message.includes('is not authorized to perform: dynamodb:GetItem')).toBeTruthy()
45 | })
46 |
47 | it('should DENY dynamodb:Query', async () => {
48 | // GIVEN
49 | const result = 48
50 | const service = new MyEntityService()
51 |
52 | // WHEN
53 | let exception
54 | try {
55 | await service.getByResult(result)
56 | } catch (error) {
57 | exception = error
58 | }
59 |
60 | // THEN
61 | expect(exception.name).toBe('AccessDeniedException')
62 | expect(exception.message.includes('is not authorized to perform: dynamodb:Query')).toBeTruthy()
63 | })
64 | }, {
65 | // Optional cleanup function - automatically called after credentials are restored
66 | cleanup: async () => {
67 | if (cleanup.length > 0) {
68 | console.log(`(Doing cleanup after test. Removing ${cleanup.length} items from DynamoDB) `)
69 | const userRoleAdapter = new DynamoDbAdapter()
70 | const deleteAll = cleanup.map((obj) => userRoleAdapter.delete({
71 | Key: obj.key(),
72 | TableName: process.env.tableName
73 | }))
74 | await Promise.all(deleteAll)
75 | cleanup.length = 0 // Clear the cleanup array
76 | }
77 | }
78 | })
79 |
--------------------------------------------------------------------------------
/src/common/adapters/DynamoDbAdapter.mjs:
--------------------------------------------------------------------------------
1 | import {
2 | TransactWriteItemsCommand, GetItemCommand, PutItemCommand, UpdateItemCommand,
3 | DeleteItemCommand, DynamoDBClient
4 | } from '@aws-sdk/client-dynamodb';
5 | import { QueryCommand, DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
6 | import { Logger } from '@aws-lambda-powertools/logger'
7 |
8 | const logger = new Logger({ serviceName: import.meta.url.split('/').pop() });
9 |
10 | export class DynamoDbAdapter {
11 | constructor() {
12 | this.client = new DynamoDBClient({
13 | region: process.env.region
14 | });
15 | this.documentClient = DynamoDBDocumentClient.from(this.client);
16 | }
17 |
18 | async queryByField(TableName, field, value) {
19 | const params = {
20 | TableName,
21 | // IndexName: indexName,
22 | KeyConditionExpression: '#field = :value',
23 | ExpressionAttributeNames: {
24 | '#field': field
25 | },
26 | ExpressionAttributeValues: {
27 | ':value': value
28 | }
29 | };
30 | return this.query(params);
31 | }
32 |
33 | async queryIndexByField(IndexName, field, value) {
34 | const params = {
35 | IndexName,
36 | KeyConditionExpression: '#field = :value',
37 | ExpressionAttributeNames: {
38 | '#field': field
39 | },
40 | ExpressionAttributeValues: {
41 | ':value': value
42 | }
43 | };
44 | return this.query(params);
45 | }
46 |
47 | async query(params) {
48 | return this.documentClient.send(new QueryCommand(params))
49 | }
50 |
51 | async get(params) {
52 | return this.client.send(new GetItemCommand(params));
53 | }
54 |
55 | async createItem(tableName, entity) {
56 | logger.info('Saving new item into DynamoDB Table', {
57 | itemId: entity.id,
58 | tableName,
59 | })
60 | const params = {
61 | Item: entity.toItem(),
62 | ReturnConsumedCapacity: 'TOTAL',
63 | TableName: tableName
64 | }
65 | try {
66 | await this.create(params)
67 | logger.info('Item saved successfully')
68 | return entity
69 | } catch (error) {
70 | logger.error('Item not saved', error)
71 | throw error
72 | }
73 | }
74 |
75 | async create(params) {
76 | return this.client.send(new PutItemCommand(params))
77 | }
78 |
79 | async delete(params) {
80 | logger.info('Deleting item', {
81 | PK: params.Key.PK.S,
82 | SK: params.Key.SK ? params.Key.SK.S : 'not present',
83 | tableName: params.TableName,
84 | })
85 | return this.client.send(new DeleteItemCommand(params))
86 | }
87 |
88 | async update(params) {
89 | return this.client.send(new UpdateItemCommand(params))
90 | }
91 |
92 | async transactWrite(params) {
93 | const transactionCommand = new TransactWriteItemsCommand(params);
94 | return this.client.send(transactionCommand)
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/__tests__/common/adapters/DynamodbAdapter.int.mjs:
--------------------------------------------------------------------------------
1 | import { DynamoDbAdapter } from '../../../src/common/adapters/DynamoDbAdapter.mjs'
2 |
3 | describe('DynamoDB Adapter', () => {
4 | it('should query by field', async () => {
5 | // GIVEN
6 | const db = new DynamoDbAdapter()
7 |
8 | // WHEN
9 | const results = await db.queryByField(process.env.tableName, 'PK', 'fake-fake-fake')
10 |
11 | // THEN
12 | expect(results).toBeTruthy()
13 | expect(results.Count).toBe(0)
14 | })
15 |
16 | it('should create & delete item', async () => {
17 | // GIVEN
18 | const db = new DynamoDbAdapter()
19 | const paramsCreate = {
20 | Item: {
21 | PK: { S: 'SampleId' },
22 | Type: { S: 'SampleId' },
23 | },
24 | ReturnConsumedCapacity: 'TOTAL',
25 | TableName: process.env.tableName
26 | }
27 | const paramsDelete = {
28 | Key: {
29 | PK: { S: 'SampleId' },
30 | },
31 | ReturnConsumedCapacity: 'TOTAL',
32 | TableName: process.env.tableName
33 | }
34 |
35 | // WHEN
36 | const createResults = await db.create(paramsCreate)
37 | const deleteResults = await db.delete(paramsDelete)
38 | const check = await db.queryByField(process.env.tableName, 'PK', 'SampleId')
39 |
40 | // THEN
41 | expect(createResults).toBeTruthy()
42 | expect(createResults.ConsumedCapacity.TableName).toMatch(process.env.tableName)
43 | expect(createResults.ConsumedCapacity.CapacityUnits).toBe(1)
44 | expect(deleteResults.ConsumedCapacity.TableName).toMatch(process.env.tableName)
45 | expect(deleteResults.ConsumedCapacity.CapacityUnits).toBe(1)
46 | expect(check.Count).toBe(0)
47 | })
48 |
49 | it('should get item', async () => {
50 | // GIVEN
51 | const db = new DynamoDbAdapter()
52 | const paramsCreate = {
53 | Item: {
54 | PK: { S: 'SampleId' },
55 | Type: { S: 'TestEntity' },
56 | },
57 | ReturnConsumedCapacity: 'TOTAL',
58 | TableName: process.env.tableName
59 | }
60 | const paramsDelete = {
61 | Key: {
62 | PK: { S: 'SampleId' }
63 | },
64 | ReturnConsumedCapacity: 'TOTAL',
65 | TableName: process.env.tableName
66 | }
67 | const paramsGet = {
68 | Key: {
69 | PK: { S: 'SampleId' }
70 | },
71 | ReturnConsumedCapacity: 'TOTAL',
72 | TableName: process.env.tableName
73 | }
74 |
75 | // WHEN
76 | const createResults = await db.create(paramsCreate)
77 | const getResults = await db.get(paramsGet)
78 | const deleteResults = await db.delete(paramsDelete)
79 | const check = await db.queryByField(process.env.tableName, 'PK', 'SampleId')
80 |
81 | // THEN
82 | expect(createResults).toBeTruthy()
83 | expect(createResults.ConsumedCapacity.TableName).toMatch(process.env.tableName)
84 | expect(createResults.ConsumedCapacity.CapacityUnits).toBe(1)
85 | expect(getResults.Item.PK.S).toBe('SampleId')
86 | expect(getResults.Item.Type.S).toBe('TestEntity')
87 | expect(deleteResults.ConsumedCapacity.TableName).toMatch(process.env.tableName)
88 | expect(deleteResults.ConsumedCapacity.CapacityUnits).toBe(1)
89 | expect(check.Count).toBe(0)
90 | })
91 | })
92 |
--------------------------------------------------------------------------------
/documentation/diagrams.drawio:
--------------------------------------------------------------------------------
1 | 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
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # serverless-hexagonal-template
2 |
3 | [](https://github.com/serverlesspolska/serverless-hexagonal-template/blob/main/LICENSE)
4 | [](https://github.com/serverlesspolska/serverless-hexagonal-template/stargazers)
5 | [](http://makeapullrequest.com)
6 |
7 |
8 | Highly opinionated project template for [Serverless Framework](https://www.serverless.com/) that applies **hexagonal architecture** principles to the serverless world. Crafted with easy testing in mind.
9 |
10 | # Recent modernization
11 |
12 | In 2025 the project received additional modernization updates:
13 | * 𝙉𝙤𝙙𝙚.𝙟𝙨 𝙍𝙪𝙣𝙩𝙞𝙢𝙚 𝙐𝙥𝙜𝙧𝙖𝙙𝙚: Upgraded from Node.js 20.x to 22.x in serverless configuration for enhanced performance and latest features.
14 | * 𝘿𝙚𝙥𝙡𝙤𝙮𝙢𝙚𝙣𝙩 𝙏𝙤𝙤𝙡 𝙈𝙞𝙜𝙧𝙖𝙩𝙞𝙤𝙣: Transitioned from serverless to [oss-serverless](https://github.com/oss-serverless/serverless) for support of Node.js 22 runtime.
15 | * 𝙀𝙎𝙇𝙞𝙣𝙩 𝙈𝙤𝙙𝙚𝙧𝙣𝙞𝙯𝙖𝙩𝙞𝙤𝙣: Migrated to ESLint 9 with neostandard configuration, removing legacy config files and updating dependencies.
16 | * 𝙏𝙚𝙨𝙩𝙞𝙣𝙜 𝙁𝙧𝙖𝙢𝙚𝙬𝙤𝙧𝙠 𝙐𝙥𝙙𝙖𝙩𝙚: Updated Jest to version 30.0.2 for better testing capabilities.
17 | * 𝙎𝙘𝙝𝙚𝙢𝙖 𝙑𝙖𝙡𝙞𝙙𝙖𝙩𝙞𝙤𝙣 𝙊𝙥𝙩𝙞𝙢𝙞𝙯𝙖𝙩𝙞𝙤𝙣: Standardized AJV command options and improved package dependencies for better compatibility.
18 | * 𝙏𝙚𝙨𝙩𝙞𝙣𝙜 𝙞𝙢𝙥𝙧𝙤𝙫𝙚𝙢𝙚𝙣𝙩𝙨: Updated [serverless-iam-test-helper
19 | ](https://github.com/serverlesspolska/serverless-iam-test-helper) testing library with better support for SSO profiles, and enhanced testing methods such `describeWithRole()`.
20 | * 𝙉𝙚𝙬 𝘼𝙪𝙩𝙝𝙚𝙣𝙩𝙞𝙘𝙖𝙩𝙞𝙤𝙣 𝙎𝙪𝙥𝙥𝙤𝙧𝙩: Added serverless-better-credentials to handle AWS SSO profiles seamlessly.
21 | * 𝙀𝙣𝙝𝙖𝙣𝙘𝙚𝙙 𝙇𝙤𝙜𝙜𝙞𝙣𝙜: Improved logging system to include filename information for better debugging experience.
22 |
23 | At the beginning of 2024 this project has been refurbished. Here's a snapshot of significant updates that have been done:
24 | * 𝘿𝙚𝙥𝙚𝙣𝙙𝙚𝙣𝙘𝙮 𝙖𝙣𝙙 𝙍𝙪𝙣𝙩𝙞𝙢𝙚 𝙐𝙥𝙜𝙧𝙖𝙙𝙚: We've successfully transitioned from Node 16 to Node 20, ensuring our project stays at the cutting edge of technology.
25 | * 𝙀𝙢𝙗𝙧𝙖𝙘𝙞𝙣𝙜 𝙈𝙤𝙙𝙚𝙧𝙣 𝙅𝙖𝙫𝙖𝙎𝙘𝙧𝙞𝙥𝙩: By shifting from require statements to import, our code now fully leverages Node modules, streamlining our development process.
26 | * 𝘼𝙒𝙎 𝙎𝘿𝙆 𝙀𝙫𝙤𝙡𝙪𝙩𝙞𝙤𝙣: Our migration from AWS SDK v2 to v3 marks a significant leap forward in efficiency and performance.
27 | * 𝙈𝙞𝙙𝙙𝙡𝙚𝙬𝙖𝙧𝙚 𝙖𝙣𝙙 𝙏𝙚𝙨𝙩𝙞𝙣𝙜 𝙀𝙣𝙝𝙖𝙣𝙘𝙚𝙢𝙚𝙣𝙩𝙨: Updates to Middy v5 middleware and aws-testing-library have fortified our project, eliminating deprecated dependencies and vulnerabilities.
28 | * 𝙊𝙥𝙩𝙞𝙢𝙞𝙯𝙞𝙣𝙜 𝘼𝙋𝙄 𝘾𝙖𝙡𝙡𝙨: Replacing Axios with native fetch has optimized our API interactions and reduced our project's complexity.
29 | * 𝙎𝙩𝙧𝙪𝙘𝙩𝙪𝙧𝙚𝙙 𝙇𝙤𝙜𝙜𝙞𝙣𝙜 𝙬𝙞𝙩𝙝 𝙋𝙤𝙬𝙚𝙧𝙏𝙤𝙤𝙡𝙨: The introduction of the PowerTools logger has transformed our logging process, enabling more effective tracking and analysis.
30 | * 𝙀𝙣𝙝𝙖𝙣𝙘𝙚𝙙 𝙋𝙚𝙧𝙛𝙤𝙧𝙢𝙖𝙣𝙘𝙚 with AJV Pre-compilation: By introducing AJV pre-compilation of schemas for Middy Validator, we've dramatically 𝗿𝗲𝗱𝘂𝗰𝗲𝗱 𝗼𝘂𝗿 𝗹𝗮𝗺𝗯𝗱𝗮 𝗽𝗮𝗰𝗸𝗮𝗴𝗲 𝘀𝗶𝘇𝗲 𝗳𝗿𝗼𝗺 𝟭.𝟳𝗠𝗕 𝘁𝗼 𝟰𝟳𝟴𝗞𝗕 (𝟳𝟮%). This significant reduction lowers cold start times and boosts overall performance.
31 | * 𝙎𝙞𝙢𝙥𝙡𝙞𝙛𝙞𝙘𝙖𝙩𝙞𝙤𝙣 𝙤𝙛 𝙘𝙧𝙚𝙙𝙚𝙣𝙩𝙞𝙖𝙡 𝙢𝙖𝙣𝙖𝙜𝙚𝙢𝙚𝙣𝙩: AWS CLI profile was removed from the configuration file due to complications it introduced in CI/CD configurations.
32 |
33 |
34 | # Quick start
35 |
36 | This is a *template* from which you can create your own project by executing following command:
37 | ```
38 | sls create --template-url https://github.com/serverlesspolska/serverless-hexagonal-template/tree/main --name your-project-name
39 | ```
40 |
41 | Next install dependencies:
42 | ```
43 | cd your-project-name
44 | npm i
45 | ```
46 | and deploy to your `dev` stage in default region:
47 | ```
48 | sls deploy
49 | ```
50 | # High-level architecture
51 | This template implements depicted below architecture. The application itself is just an example used to show you how to test serverless architectures.
52 |
53 | You can easily modify the source code and tailor it to your needs.
54 | 
55 | # Why use this template?
56 | This template project was created with two goals in mind: ***streamlined developer's flow*** and ***easy testing***, because, sadly, both are not common in serverless development yet.
57 |
58 | ## Standardized structure
59 | The project structure has been worked out as a result of years of development in Lambda environment using Serverless Framework. It also takes from the collective experience of the community (to whom I am grateful) embodied in books, talks, videos and articles.
60 |
61 | This template aims to deliver ***common structure*** that would speed up development by providing sensible defaults for boilerplate configurations. It defines *where what* should be placed (i.e. source code in `src/` folder, tests in `__tests__` etc.) so you don't need to waste time on thinking about it every time you start new project and creates common language for your team members. Thus, decreasing *cognitive overload* when switching between projects started from this template.
62 |
63 | ### Structure explanation
64 | There are some guidelines in terms of folders structure and naming conventions.
65 |
66 | |Path|Description|Reason|
67 | |-|-|-|
68 | |`./__tests__`|default folder name for tests when using `jest`. Substructure of this folder follows ***exactly*** the `./src/` structure.|Don't keep tests together with implementation. Easier to exclude during deployment. Easier to distinguish between code and implementation.|
69 | |`./config`|all additional config files for `jest` and deployment|`deployment.yml` is included in `serverless.yml`, it is separate because when you have multiple microservices making single system they should share same *stages* and *regions*. Also you can put VPC configuration there.|
70 | |`./documentation`|You may keep here any documentation about the project.||
71 | |`./src`|Implementation code goes here|This is a widespread convention.|
72 | |`./src//`|Each Lambda function has it's own folder|Better organization of the code.|
73 | |`./src//function.js`|Every file that implements Lambda's `handler` method is named `function.js`. The handler method name is always `handler`|Easy to find Lambda handlers.|
74 | |`./src/common/`|Place where common elements of implementation are stored. Most implementation code goes here. You should follow [Single-responsibility principle](https://en.wikipedia.org/wiki/Single-responsibility_principle).|**Makes testing possible and really easy**!|
75 | |`./src/common/adapters/Adapter.js`|For files implementing *technical* part of an Adapter in terms of Hexagonal Architecture|This should be very generic i.e. class that allows connections to MySQL **without** any particular SQL code that you want to execute in your usecase.|
76 | |`./src/common/entities/.js`|Here you keep files that represent objects stored in your *repository* (database)|Useful when you use database 😉|
77 | |`./src/common/services/Service.js`|For files implementing *business* part of an Adapter in terms of Hexagonal Architecture|This service uses corresponding `Adapter.js` adapter or adapters. You would put a specific SQL code here and pass it to adapter instance to execute it.|
78 |
79 | After working a lot with that structure I ***saw a pattern emerging*** that `Adapter.js` together with `Service.js` they both implement an adapter in terms of Hexagonal Architecture. Moreover, usually well written adapters (`Adapter.js`) can be reused among different projects (can be put into separate NPM package).
80 |
81 | ### File naming conventions
82 | If a file contains a `class` it should be named starting with big letter i.e `StorageService.js`. If file does not contain a class but one or many JavaScript functions name start from small letter i.e `s3Adapter.js`.
83 |
84 | ## Hexagonal architecture
85 | Design of the code has been planned with hexagonal architecture in mind. It allows better separation of concerns and makes it easier to write code that follows single responsibility principle. Those are crucial characteristics that create architectures which are easy to maintain, extend and test.
86 |
87 | ## Easy testing
88 | Tests are written using `jest` framework and are divided into three separate groups:
89 | * unit
90 | * integration
91 | * end to end (e2e)
92 |
93 | 
94 | Note: Unit tests not shown on the diagram for clarity.
95 | ### Unit tests
96 | Those tests are executed locally (on developers computer or CI/CD server) and don't require access to any resources in the AWS cloud or on the Internet.
97 |
98 | Unit tests are ideal to test your *business logic*. You may also decide to test your *services* (located at `src/common/services`). Both are really easy to do when using **hexagonal architecture**.
99 |
100 | Please **don't** mock whole AWS cloud in order to test everything locally. This is wrong path. Just don't do that. 😉
101 |
102 | ```
103 | npm run test
104 | ```
105 |
106 | ### Integration tests
107 |
108 | Integration tests focus on pieces of your code (in OOP I'd say *classes*), that realize particular *low* & *mid* level actions. For example your Lambda function may read from DynamoDB, so you would write a *service* and *adapter* modules (classes) that would implement this functionality. Integration test would be executed against **real** DynamoDB table provisioned during deployment of that project. However the code will be running locally (on you computer or CI/CD server).
109 |
110 | 
111 |
112 | Those tests require resources in the cloud. In order to execute them you first need to *deploy* this project to AWS.
113 | ```
114 | sls deploy
115 | npm run integration
116 | ```
117 | Those commands deploy project to the cloud on `dev` stage and execute integration tests.
118 |
119 | The `npm run integration` command executes underneath the `serverless-export-env` plugin that exports environment variables set for each Lambda function in the cloud. Results of that command are saved locally to the `.awsenv` file. This file is later injected into `jest` context during tests.
120 |
121 | There is also a *shortcut* command that executes tests but doesn't execute `serverless-export-env` plugin. It requires the `.awsenv` file to be already present. Since environment variables don't change that often this can save you time.
122 | ```
123 | npm run int
124 | ```
125 |
126 | ### End to end tests (e2e)
127 | End to end tests focus on whole use cases (from beginning to the end) or larger fragments of those. Usually those tests take longer than integration tests.
128 |
129 | An example of such test would be `POST` request sent to API Gateway `/item` endpoint and a check if `processItem` Lambda function was triggered by DynamoDB Streams as a result of saving new item by `createItem` Lambda function invoked by the request. Such approach tests *chain of events* that happen in the cloud and **gives confidence** that integration between multiple services is well configured.
130 |
131 | 
132 |
133 | This test is implemented in `__tests__/processItem/functionIsTriggeredByDdbStream.e2e.js`
134 |
135 | You can test it yourself by executing:
136 | ```
137 | npm run e2e
138 | ```
139 |
140 | #### Run all tests
141 | Convenience command has been added for running all test. Please bare in mind it requires deployed service.
142 | ```
143 | npm run all
144 | ```
145 | **Note**:
146 | > Tests will be executed against your individual development environment - [see section below](#deployment). If you want to execute all test on `dev` stage, please execute `npm run all-dev` command.
147 |
148 | #### DEBUG mode
149 | If you want to see logs when running tests on your local machines just set environment variable `DEBUG` to value `ON`. For example:
150 | ```
151 | DEBUG=ON npm run test
152 | # or
153 | DEBUG=ON npm run integration
154 | ```
155 |
156 | #### GUI / acceptance tests
157 | End to end tests are not a substitution to GUI or acceptance tests. For those other solutions (such as AWS CloudWatch Synthetics) are needed.
158 |
159 | ## Deployment
160 | ### Isolated per developer stages (multiple development environments)
161 | Many users asked me to add a **feature allowing developers to work in parallel on isolated stages**. Common *best practice* says that each developer should use a separate AWS account for development to avoid conflicts. Unfortunately, for many teams and companies, managing multiple AWS accounts poses a challenge of its own.
162 |
163 | In order to remove that obstacle, I decided to implement a simple solution that would allow many developers to use a single AWS account for development. **Remember, your production stage should be deployed on a different AWS account for security and performance reasons!**
164 |
165 | When executing the `serverless` or `sls` command without the` -s` (stage) parameter, your `username` will be used to name the stage.
166 |
167 | > For example, my user name on my laptop is `pawel` therefore, the stage will be named `serverless-hexagonal-template-dev-pawel`. Settings such as deployment `region` will be inherited from the dev configuration.
168 |
169 | In that way, your colleagues can deploy their own stages (development environments) in the same region on the same AWS account without any conflicts (given that their usernames are unique in the scope of your project).
170 |
171 | To use that feature, simply execute command without providing any stage name:
172 | ```
173 | sls deploy
174 | ```
175 |
176 | ### Regular deployment
177 | Deployment to `dev` stage.
178 | ```
179 | sls deploy -s dev
180 | ```
181 | Deployment to a specific stage
182 | ```
183 | sls deploy -s # stage = dev | test | prod
184 | ```
185 |
186 | The stages configuration is defined in `config/deployment.yml` file.
187 |
188 | ### Deployment credentials
189 |
190 | #### Changes to AWS CLI `profile` configuration
191 | In the previous version of this template, the AWS CLI `profile` was specified in the Serverless Framework configuration file (`config/deployment.yml`) and utilized during the deployment process. This approach has been phased out due to complications it introduced in CI/CD configurations.
192 |
193 | The template now adheres to the standard AWS and Serverless Framework credentials resolution method as outlined in the [AWS documentation](https://docs.aws.amazon.com/sdkref/latest/guide/standardized-credentials.html).
194 |
195 | #### Credentials Resolution Order
196 | The system will attempt to resolve your credentials in the following order:
197 | 1. Environment variables `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are checked first.
198 | 1. If not found, the `default` AWS `profile` is used.
199 | 1. Other / custom method.
200 |
201 | #### Custom profile configuration
202 | For those requiring a different `profile` than the `default`, it is recommended to use the `direnv` tool. This allows you to specify an AWS profile for your project within the `.envrc` file located at the project root directory, overriding system settings. Ensure that the AWS profile is already defined in your `~/.aws/credentials` file or `~/.aws/config` if you use SSO.
203 |
204 | To set up `direnv`, follow these steps:
205 |
206 | 1. Define your AWS profile in the `.envrc` file to automatically use it within the project's directory and its subdirectories.
207 | ```Bash
208 | # Set a default profile for this directory
209 | export AWS_PROFILE=my-dev-profile
210 | ```
211 | 2. Alternatively, you can directly set your access keys:
212 | ```Bash
213 | # Set AWS access keys directly
214 | export AWS_ACCESS_KEY_ID=
215 | export AWS_SECRET_ACCESS_KEY=
216 | ```
217 | **Note**: These credentials are utilized not only during the deployment process but also for integration and end-to-end testing.
218 |
219 | For more information on direnv and its setup, visit https://direnv.net.
220 |
221 | # What's included?
222 |
223 | Serverless Framework plugins:
224 | - [serverless-iam-roles-per-function](https://github.com/functionalone/serverless-iam-roles-per-function) - to manage individual IAM roles for each function
225 | - [serverless-export-env](https://github.com/arabold/serverless-export-env) - to export Lambda functions environment variables in `.awsenv` file
226 |
227 |
228 | Node.js development libraries:
229 |
230 | * AWS SDK
231 | * Eslint with modified airbnb-base see `.eslintrc.yml`
232 | * Jest
233 | * dotenv
234 | * [aws-testing-library](https://github.com/erezrokah/aws-testing-library) for *end to end* testing
235 | * [serverless-logger](https://github.com/serverlesspolska/serverless-logger)
236 |
--------------------------------------------------------------------------------