Lambda hexagonal architecture variation
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.
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.
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
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
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
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
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')
})
Credits: LibreOffice Draw.
Repository: daaru00/lambda-hexagonal-architecture