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.