Lambda hexagonal architecture variation

Fabio Gollinucci
7 min readJul 3, 2023

--

Given the large number of blog articles and conference talks on the subject I wanted to do some testing myself.

I started from aws-samples/aws-lambda-hexagonal-architecture repository and related blog post developing-evolutionary-architecture-with-aws-lambda. I then started adding APIs methods, events, queues and tables in order to understand how the solution scaled.

Hexagonal schema

Some warm comments:

  • There is a single template.yaml where all functions and resource will be declared. Can it scale to different stacks as well? Better to use Lambda Layer? This depends on the size of the service you want to implement.
  • Primary and secondary agent adapters are in the same directory, as files grow it becomes necessary to organize the files and functions within them in some way.
  • Lambda handlers proliferate, outside the implementation, in a single app.js? This can be hard to manage with a lot of APIs methods.

Scale up implementations

By adding methods to the API, such as a set of user and cart functionality:

GET /me -> get current logged user and related info
POST /me -> set user additional info
GET /products -> list products
GET /cart -> get current cart
POST /cart/add -> add product to cart
POST /cart/remove -> remove product from cart

Quickly the files and functions began to grow in number.

Implementations schema

According to the hexagonal pattern, for each exposed API method an adapter is connected, the adapter has a connected port which interacts with a domain function.

POST /me -> adapter setUserInfo -> port setUserInfo -> domain saveUserInfo

The domain function will then contact a port and its adapter towards the database.

domain saveUserInfo -> port saveUser -> adapter putItem

This results in 2 adapters, 2 ports, 1 domain function, for each APIs method of the given example results in 30 “thing” to implement. It is well known that this type of pattern is verbose, it has the purpose of segregating the various layers and being able to stop the stack and test its parts.

Additionally the Lambda function handler needs to be implemented in app.js for each API, event handler or queue consumer declaration.

Testing

Testing schema

What about testing? Using NodeJS the module or files requirements could be a pain. If I want to test an app functionality every portion of the stack ends in a secondary agent adapter call.

adapter setUserInfo -> port setUserInfo -> domain saveUserInfo -> port saveUser -> adapter putItem
aport setUserInfo -> domain saveUserInfo -> port saveUser -> adapter putItem
domain saveUserInfo -> port saveUser -> adapter putItem
port saveUser -> adapter putItem

For example, for database layout there are some testing scenarios:

  • Using a remote DynamoDB table
  • Using a local DynamoDB endpoint
  • Mocking DynamoDB call

Obviously the starting point was minimal, to make the pattern work the various parts of the system (adapters, ports, domain) must implement a dependency injection system.

In pure JavaScript this can be archived using a class passing dependencies into constructor:

class UserRepository {
constructor(db) {
this.db = db;
}

async getUser(userId) {
const user = await this.db.getUserInfo(userId)
}
}

Or using am additional function parameters:

export default async function ({ userId }, context = { db }) {
const user = await context.db.getUserInfo(userId)
}

This in order to facilitate the mocking:

// prepare mocking
const db = {
async getUserInfo({ userId }) {}
}

// call port with context override
const user = await getUser({ userId }, { db })

Some modifications and adaptions

Looking forward to this implementation I personally see a lot of files, sometimes in the same directory.

Actors identification

The first change that I made is separate primary and secondary agents adapters to make it more clear if it’s something that “receives” or “sends” information.

adapters
├── primary
│ ├── api
│ │ ├── addToCart.mjs
│ │ ├── getCart.mjs
│ │ ├── getCurrentUser.mjs
│ │ ├── listProducts.mjs
│ │ ├── removeFromCart.mjs
│ │ └── setUserInfo.mjs
│ ├── cli
│ │ └── import.mjs
│ ├── events
│ │ ├── onProductUpdated.mjs
│ │ └── onUserRegistered.mjs
│ └── queue
│ └── sendEmail.mjs
└── secondary
├── crm.mjs
├── db.mjs
├── email.mjs
├── events.mjs
└── queue.mjs

Primary actor types

Next change regards primary agent type, if it comes from API, events or queue consumers. I separate adapters based on Lambda integration type, this makes them more identifiable.

adapters/primary
├── api
│ ├── addToCart.mjs
│ ├── getCart.mjs
│ ├── getCurrentUser.mjs
│ ├── listProducts.mjs
│ ├── removeFromCart.mjs
│ └── setUserInfo.mjs
├── cli
│ └── import.mjs
├── events
│ ├── onProductUpdated.mjs
│ └── onUserRegistered.mjs
└── queue
└── sendEmail.mjs

Adapter as handler

Handlers schema

I directly use the primary adapter as Lambda handler without having to pass through a single the app.js file.

// src/adapters/primary/api/setUserInfo.mjs

import api from '../../../utils/api.mjs'
import setUserInfo from '../../../ports/user/setUserInfo.mjs'

export async function handler(event) {
const auth = api.getRequestAuth(event)
if (!auth) {
return api.respondClientFail('Invalid Auth')
}

const info = api.getRequestBody(event)

try {
await setUserInfo(auth.userId, info)
} catch (error) {
return api.respondClientFail(error.message)
}

return api.respondSuccess()
}

The adapter “know” that will be called by Lambda, with specific event configurations. Utilities will help to parse and format different event types.

Improve port usage

Port usage schema

In order to reduce the call chain I tried to directly connect ports and adapters that don’t need so much “domain logic".

// src/ports/user/setUserInfo.mjs

import validateUser from '../../domains/user/validateUser.mjs'
import db from '../../adapters/secondary/db.mjs'
import crm from '../../adapters/secondary/crm.mjs'

export default async function (userId, info) {
// port validation
if (!userId) {
throw new Error('Invalid User Id')
}

// data validation
validateUser(info)

// logic operations
const user = await db.putUserInfo(userId, info)

await crm.updateUser(user)
}

In order to take control of both request input and output the domain logic can expose a filter function that elaborate the data before port return it:

// src/ports/user/getUserInfo.mjs

import db from '../../adapters/secondary/db.mjs'
import filterPublicAttributes from '../../domains/user/filterPublicAttributes.mjs'

export default async function (userId) {
if (!userId) {
throw new Error('Invalid User Id')
}

const user = await db.getUserInfo(userId)

return filterPublicAttributes(user)
}

Additionally the port can be detached from secondary adapters using a dependencies injection:

// src/ports/user/getUserInfo.mjs

import db from '../../adapters/secondary/db.mjs'
import filterPublicAttributes from '../../domains/user/filterPublicAttributes.mjs'

export default async function ({ userId }, context = { db }) {
if (!context.db) {
throw new Error('Invalid Context')
}

if (!userId) {
throw new Error('Invalid User Id')
}

const user = await context.db.getUserInfo(userId)

return filterPublicAttributes(user)
}

With the new context parameter is now possible to both using the real implements and the mocked one depends on the context in which the code is executed (live or test).

// test/ports/user/getUserInfo.test.mjs

import db from '../../../src/adapters/secondary/db.mjs'

// mock database implementation
const spyGetUserInfo = vi.spyOn(db, 'getUserInfo').mockImplementation(async (userId) => ({
id: userId,
name: 'John',
surname: 'Doe',
}))

test('getUserInfo - valid id', async () => {
const userId = 'xxxxxx'

// override context
const user = await getUserInfo({ userId }, { db })

expect(user).toBeTypeOf('object')
expect(user.name).toBe('John')
expect(user.surname).toBe('Doe')

// check spy execution
expect(spyGetUserInfo).toBeCalledWith(userId)
})

Keep the domain safe

Invalid domain-adapter relation schema

I used the domain layer just to declare atomic operations required for the project (data validation, constants, cart total calculations).

// src/domains/user/validateUser.mjs

export default function (user) {
if (!user || [user.name, user.surname].some(v => !v)) {
throw new Error('Invalid Required Infos')
}
}

With this change I’m now able to write unit testing for domain functions and make it more testable in isolation.

// test/domains/user/validateUser.test.mjs

import { test, expect } from 'vitest'
import validateUser from '../../../src/domains/user/validateUser'

const INVALID_REQUIRED_INFO_ERROR = 'Invalid Required Infos'

test('validateUser - valid user', () => {
expect(() => {
validateUser({
name: 'john',
surname: 'doe'
})
}).not.toThrowError(INVALID_REQUIRED_INFO_ERROR)
})

test('validateUser - invalid user', () => {
expect(() => {
validateUser({})
}).toThrowError(INVALID_REQUIRED_INFO_ERROR)

expect(() => {
validateUser({
name: 'john',
})
}).toThrowError(INVALID_REQUIRED_INFO_ERROR)

expect(() => {
validateUser({
surname: 'doe'
})
}).toThrowError(INVALID_REQUIRED_INFO_ERROR)
})

Utilities

I also move some utility functions in a dedicated directory. I will place here logic that can be easily ported into another project (API response format, numbers and strings operations..).

src/utils
├── api.mjs
├── events.mjs
└── queue.mjs

Also the utility layer can be tested in isolation to make it easy to write unit tests.

// test/utils/api.test.mjs

import { test, expect } from 'vitest'
import api from '../../src/utils/api'

test('getRequestBody - simple key value', () => {
const body = api.getRequestBody({
body: '{"key":"value"}'
})

expect(body).toBeTypeOf('object')
expect(body).toHaveProperty('key')
expect(body.key).toBe('value')
})

--

--