├── 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 | [![GitHub license](https://img.shields.io/github/license/serverlesspolska/serverless-hexagonal-template)](https://github.com/serverlesspolska/serverless-hexagonal-template/blob/main/LICENSE) 4 | [![GitHub stars](https://img.shields.io/github/stars/serverlesspolska/serverless-hexagonal-template)](https://github.com/serverlesspolska/serverless-hexagonal-template/stargazers) 5 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](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 | ![High-level architecture](documentation/high-level.png) 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 | ![Testing diagram](documentation/testing.png) 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 | ![Integration tests](documentation/testing-int.png) 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 | ![e2e test](documentation/testing-e2e.png) 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 | --------------------------------------------------------------------------------