It is an architectural pattern, that aims decouple an application from details, such as ORM, Database, Third party APIs, Application Layer Protocol and web framework. This pattern lets you easily swap technologies when needed without tightly coupling your codebase. The communication between hexagons is done via HTTP
Port
Port is a contract that your application uses to communicate with external resources. Port will usually have an interface with defined methods that are used for communication, and a method or class that exposes those methods to your adapters. Ports are strictly defined by a business logic without mentioning the technology behind them
Adapter
Adapter is a abstraction that allows you to plug technologies into your application. Essentially, they transform the reason to communication into communication with a technology. There are two types of adapters:
Primary
Primary (sometimes called Driver) are responsible for receiving signals and events from a specific technology and converting them to a port. This will usually be a REST api, Message Queue, cron job etc.
Secondary
Secondary (sometimes called Driven) adapters are responsible for converting commands from a port to a specific technology.
Example
Below is an example of User domain that uses this pattern.
// user-service/src/domain/User.ts
export interface User {
id: string;
name: string;
email: string;
}
// Ports
// Primary Port (Driving Port)
// user-service/src/ports/UserService.ts
export interface UserService {
createUser(name: string, email: string): Promise<User>;
getUserById(id: string): Promise<User | null>;
}
// Secondary Port (Driven Port)
// user-service/src/ports/UserRepository.ts
export interface UserRepository {
save(user: Omit<User, 'id'>): Promise<User>;
findById(id: string): Promise<User | null>;
}
// user-service/src/core/UserServiceImpl.ts
import { UserService } from '../ports/UserService';
import { UserRepository } from '../ports/UserRepository';
import { User } from '../domain/User';
export class UserServiceImpl implements UserService {
constructor(private userRepository: UserRepository) {}
async createUser(name: string, email: string): Promise<User> {
return this.userRepository.save({ name, email });
}
async getUserById(id: string): Promise<User | null> {
return this.userRepository.findById(id);
}
}
// user-service/src/adapters/DynamoDBUserRepository.ts
import { DynamoDB } from 'aws-sdk';
import { UserRepository } from '../ports/UserRepository';
import { User } from '../domain/User';
export class DynamoDBUserRepository implements UserRepository {
private dynamoDB: DynamoDB.DocumentClient;
private tableName: string;
constructor() {
this.dynamoDB = new DynamoDB.DocumentClient();
this.tableName = process.env.USERS_TABLE_NAME || 'Users';
}
async save(user: Omit<User, 'id'>): Promise<User> {
const id = Date.now().toString();
const newUser = { id, ...user };
await this.dynamoDB.put({
TableName: this.tableName,
Item: newUser,
}).promise();
return newUser;
}
async findById(id: string): Promise<User | null> {
const result = await this.dynamoDB.get({
TableName: this.tableName,
Key: { id },
}).promise();
return result.Item as User | null;
}
}
// user-service/src/adapters/LambdaHandler.ts
import { APIGatewayProxyHandler } from 'aws-lambda';
import { UserServiceImpl } from '../core/UserServiceImpl';
import { DynamoDBUserRepository } from './DynamoDBUserRepository';
const userRepository = new DynamoDBUserRepository();
const userService = new UserServiceImpl(userRepository);
export const createUser: APIGatewayProxyHandler = async (event) => {
try {
const { name, email } = JSON.parse(event.body || '{}');
const user = await userService.createUser(name, email);
return {
statusCode: 201,
body: JSON.stringify(user),
};
} catch (error) {
return {
statusCode: 400,
body: JSON.stringify({ error: 'Failed to create user' }),
};
}
};
export const getUser: APIGatewayProxyHandler = async (event) => {
try {
const id = event.pathParameters?.id;
if (!id) {
return {
statusCode: 400,
body: JSON.stringify({ error: 'User ID is required' }),
};
}
const user = await userService.getUserById(id);
if (user) {
return {
statusCode: 200,
body: JSON.stringify(user),
};
} else {
return {
statusCode: 404,
body: JSON.stringify({ error: 'User not found' }),
};
}
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify({ error: 'Failed to retrieve user' }),
};
}
};
// Infrastructure as Code (using AWS CDK)
// user-service/infrastructure/UserServiceStack.ts
import * as cdk from '@aws-cdk/core';
import * as dynamodb from '@aws-cdk/aws-dynamodb';
import * as lambda from '@aws-cdk/aws-lambda';
import * as apigateway from '@aws-cdk/aws-apigateway';
export class UserServiceStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// DynamoDB table
const userTable = new dynamodb.Table(this, 'UsersTable', {
partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
});
// Lambda functions
const createUserLambda = new lambda.Function(this, 'CreateUserLambda', {
runtime: lambda.Runtime.NODEJS_14_X,
handler: 'src/adapters/LambdaHandler.createUser',
code: lambda.Code.fromAsset('..'),
environment: {
USERS_TABLE_NAME: userTable.tableName,
},
});
const getUserLambda = new lambda.Function(this, 'GetUserLambda', {
runtime: lambda.Runtime.NODEJS_14_X,
handler: 'src/adapters/LambdaHandler.getUser',
code: lambda.Code.fromAsset('..'),
environment: {
USERS_TABLE_NAME: userTable.tableName,
},
});
// Grant permissions
userTable.grantReadWriteData(createUserLambda);
userTable.grantReadData(getUserLambda);
// API Gateway
const api = new apigateway.RestApi(this, 'UserServiceApi');
const users = api.root.addResource('users');
users.addMethod('POST', new apigateway.LambdaIntegration(createUserLambda));
const user = users.addResource('{id}');
user.addMethod('GET', new apigateway.LambdaIntegration(getUserLambda));
}
} Pros
This pattern promotes isolation of business logic from technology which makes it easier to swap them when needed
Cons
This pattern adds implementation complexity.