The hexagonal, ports and adapters architecture promotes separation of concerns and testability by dividing an application into business logic with input and output adapters.
In this guide we will demonstrate a practical implementation in Node.js using MongoDB for the database and TypeScript for static typing.
We'll define the core domain logic and ports, implement MongoDB and REST adapters, and wire them together in a decoupled architecture. By the end, you'll understand the structure and benefits of ports and adapters for building maintainable, testable Node.js applications.
Introduction to The Hexagonal Architecture
Hexagonal Architecture is a software architecture that promotes a clean and modular approach to building applications. Developed by Alistair Cockburn, this architectural style helps developers create systems that are maintainable, scalable, and easy to test.
The central idea behind Hexagonal Architecture is to separate the core business logic from the external dependencies. By dividing the application into three main components, Hexagonal Architecture achieves this separation of concerns and testability :
Ports (Interfaces) π
Ports represent the contract between the Application Core and the outside world. They define the boundaries through which the core interacts with external systems or actors. In Hexagonal Architecture, ports are expressed as interfaces or abstract classes. By using interfaces, the Application Core remains decoupled from the specific implementations of the external systems.
Adapters π³
Adapters are responsible for implementing the ports and bridging the gap between the Application Core and external resources. They convert external data into formats understandable by the application and vice versa. Adapters can be of different types, such as database adapters, web service adapters, or user interface adapters.
The separation of concerns achieved by Hexagonal Architecture allows developers to focus on different parts of the system independently, making the codebase easier to maintain and extend. In the next section weβll look into the layers that help in the separation of concerns in the hexagonal architecture.
Hexagonal Architecture Layers
Hexagonal Architecture adopts a layered approach, ensuring a clear separation of concerns. The layers in the architecture are as follows :
Domain Layer π
This innermost layer is the heart of the application. It contains the domain model, entities, value objects, and business rules. The domain layer is oblivious to external systems and focuses solely on representing the business logic. By keeping the Domain Layer clean and independent of external dependencies, it becomes easier to refactor and modify the core business rules without affecting the infrastructure.
Application Layer π²
The Application Layer orchestrates the use cases by invoking the appropriate methods from the Domain Layer. It acts as the mediator between the user interfaces and the Domain Layer. The Application Layer is also responsible for managing transactions and security.
By separating the application-specific concerns from the core business logic, the Application Layer becomes a clear entry point for handling user interactions.
Infrastructure Layer π€οΈ
The outermost layer is the Infrastructure Layer, housing all the adapters for database sources and communication. It provides implementations for the Ports defined in the Application Core, enabling communication with external systems, such as databases, external APIs, and message queues. By keeping the Infrastructure Layer separate from the core business logic, the system gains flexibility in adopting new technologies and third-party services without affecting the essential business rules.
We have so far covered the vital parts of hexagonal architecture, in the next section weβll look at the advantages and key benefits this architecture offers.
Key Benefits of Hexagonal Architecture
Implementing Hexagonal Architecture in your projects can yield various advantages :
Testability π
Suppose you are building a product management application that allows users to manage their inventory, track sales, and analyze product performance. With Hexagonal Architecture, you can easily create unit tests for the core business logic of the product management application without relying on real databases or external APIs.
By mocking the database and other external dependencies, you can test various product management scenarios, such as adding new products, updating inventory levels, or generating sales reports, in isolation since .
This testing capability ensures that the product management application behaves correctly, regardless of the specific database or external services used. It also allows you to catch and fix bugs early in the development process, leading to a more robust and reliable application.
Modularity βΉοΈ
Hexagonal Architecture promotes a modular design, which enhances the maintainability of your application. Each component of the architecture has clear responsibilities and well-defined boundaries, reducing the risk of tightly coupled code.
With a clear separation between the Application Layer, Domain, Ports, and Adapters, it becomes easier to understand the codebase and make changes without inadvertently affecting other parts of the system. This modularity facilitates code reuse, makes it simpler to add new features, and reduces the impact of changes, making the codebase more robust and maintainable over time.
A MODULARITY CASE SCENARIO : Imagine you are developing a content management system (CMS) using Hexagonal Architecture. If you need to introduce a new content type, such as "events" you can create new ports and adapters for handling events without modifying the existing core logic. This modularity allows you to add new features to your CMS with minimal disruption to the existing codebase, making development more efficient and reliable.
Scalability πͺ
With Hexagonal Architecture, your application becomes more scalable. The clear boundaries defined by ports allow you to swap or add adapters to accommodate new requirements, such as using different databases or external APIs.
A SCALABLE CASE SCENARIO : Suppose your social networking platform, built with Hexagonal Architecture, experiences rapid growth in user activity. To handle the increased database load, you can replace the existing database adapter with a more scalable and performant solution. Since the Application Core is decoupled from the database implementation, this change can be made in the Infrastructure Layer, enabling your application to scale without impacting the core functionality.
Independence of Technologies π‘
Hexagonal Architecture allows you to choose and switch technologies for the adapters independently, without affecting the core business rules. This independence provides the flexibility to select the most suitable tools and services for each aspect of your application.
A CASE SCENARIO FOR INDEPENDENT TECHNOLOGIES : Consider an e-commerce application that needs to integrate with various payment gateways. With Hexagonal Architecture, you can create different payment gateway adapters, each implementing the same port (Interface). This enables you to switch between payment gateways without making changes to the core logic. You can easily experiment with different payment providers to find the one that best suits your application's needs.
Flexibility π’
Hexagonal Architecture empowers developers to make changes and experiment with the application's components without affecting the overall system. You can replace or modify adapters as needed, enabling rapid prototyping and iterative development. π
A CASE SCENARIO FLEXIBILITY : Suppose you have developed a mobile application using Hexagonal Architecture, and you want to experiment with a different user interface framework to enhance user experience. With Hexagonal Architecture, you can implement a new user interface adapter using the new framework without rewriting the entire application. You can then compare the performance, usability, and user feedback before deciding to fully adopt the new framework.
Creating a project with Ports and Adapters in NodeJS
The hexagonal architecture pattern promotes separation of concerns for maintainable apps.
This section demonstrates implementing ports and adapters in Node.js with MongoDB and TypeScript - defining the domain and ports, building MongoDB and REST adapters, and wiring them together in a decoupled architecture.
Setting up the Project
Create a new TypeScript project and install the required dependencies :
# make demo app folder
mkdir hexarch-demo-app
# cd into the app folder
cd hexarch-demo-app
# initialize npm
npm init -y
# installing dependencies
npm install express cors body-parser mongoose axios papaparse
# installing devDependencies
npm install @types/node @types/express @types/cors @types/body-parser @types/mongoose @types/papaparse --save-dev
The Project Structure
Create the following project structure :
hexarch-demo-app
βββ src
β βββ adapters
β β βββ database
β β β βββ mongo.ts
β β βββ csv
β β βββ csv-parser.ts
β βββ domain
β β βββ product.ts
β βββ ports
β β βββ http
β β β βββ controllers
β β β β βββ product-controller.ts
β β β βββ routes.ts
β β βββ repository
β β βββ product-repository.ts
β βββ app.ts
β βββ server.ts
βββ .env.example
βββ products.csv
βββ index.ts
Database Setup with MongoDB Atlas
- Go to https://www.mongodb.com/cloud/atlas and sign up or log in.
- Create a new project and cluster.
- Get the MongoDB connection string (a URI).
Implement a CSV Parser
In src/adapters/csv/csv-parser.ts
, implement a CSV parser to read the sample CSV file. For simplicity, we'll use the PapaParse library for parsing CSV data.
import Papa from 'papaparse';
import fs from 'fs';
// export a function to parse a CSV file
export const parseCSVFile = (filePath: string): Promise<any[]> => {
// return a new Promise
return new Promise((resolve, reject) => {
// use fs to read the file
fs.readFile(filePath, 'utf8', (err, data) => {
// if there is an error, reject the Promise with the error
if (err) {
console.error('error reading CSV file:', err);
reject(err);
// otherwise, parse the CSV data
} else {
const results = Papa.parse(data, {
header: true,
skipEmptyLines: true,
});
// resolve the Promise with the parsed data
resolve(results.data);
}
});
});
}
This code reads a CSV file using PapaParse library, parses it, and returns the data as an array asynchronously.
Our first MongoDB Adapter
In src/adapters/database/mongo.ts
, implement the MongoDB adapter to connect to the database and interact with the products collection.
import mongoose from 'mongoose';
import { Product } from '../../domain/product';
const MONGODB_URI = "[enter URI here]";
// set timeout limits
mongoose.connect(MONGODB_URI, {
socketTimeoutMS: 30000,
connectTimeoutMS: 30000
});
const db = mongoose.connection;
// handle MongoDB connection events
db.on('error', (error) => {
console.error('mongoDB connection error:', error);
});
db.once('open', () => {
console.log('connected to MongoDB');
});
// define the product schema
const productSchema = new mongoose.Schema({
// name field is required
name: { type: String, required: true },
// price field is required
price: { type: Number, required: true },
description: { type: String },
});
// create the ProductModel using the schema
export const ProductModel = mongoose.model('Product', productSchema);
// create the MongoProductRepository
export class MongoProductRepository {
async save(product: Product): Promise<Product> {
// validate the product before saving it to the database
if (!product.name || !product.price) throw new Error('Product name and price are required.');
const productModel = new ProductModel(product);
try {
await productModel.save();
return product;
} catch (error) {
console.error('error saving product to the database:', error);
throw error;
}
}
async getAll(): Promise<Product[]> {
try {
return ProductModel.find();
} catch (error) {
console.error('error fetching products from the database:', error);
throw error;
}
}
}
This code connects to MongoDB using Mongoose, defines a product schema, and provides functions to save and fetch products from the database. It also handles connection events and errors.
The Product Domain Model
In src/domain/product.ts
, define the Product domain model :
// export the product interface
export interface Product {
name: string;
price: number;
description?: string;
}
Product Repository
In src/ports/repository/product-repository.ts
, define the ProductRepository
interface and implement the MongoDB ProductRepository
.
import { Product } from '../../domain/product';
import { ProductModel } from '../../adapters/database/mongo';
// interface for ProductRepository
export interface ProductRepository {
// method to save a Product
save(product: Product): Promise<Product>;
// method to get all Products
getAll(): Promise<Product[]>;
}
// MongoProductRepository implements ProductRepository
export class MongoProductRepository implements ProductRepository {
// save Product implementation
async save(product: Product): Promise<Product> {
// create Mongoose model from Product
const productModel = new ProductModel(product);
try {
// save to MongoDB
await productModel.save();
// return saved Product
return product;
} catch (error) {
// log any errors
console.error('error saving product to the database:', error);
// bubble up error
throw error;
}
}
// get all Products implementation
async getAll(): Promise<Product[]> {
try {
// find Products using Mongoose model
return ProductModel.find();
} catch (error) {
// log any errors
console.error('error fetching products from the database:', error);
throw error;
}
}
}
This code defines a ProductRepository
interface with save and getAll methods, and implements it using MongoProductRepository
which interacts with the MongoDB database using Mongoose.
The Product Controller
In src/ports/http/controllers/product-controller.ts
, implement the ProductController
responsible for handling HTTP requests related to products :
import { Request, Response } from 'express';
import { Product } from '../../../domain/product';
import { ProductRepository } from '../../repository/product-repository';
export class ProductController {
// dependency inject ProductRepository
private productRepository: ProductRepository;
constructor(productRepository: ProductRepository) {
this.productRepository = productRepository;
}
// post /products endpoint
async createProduct(req: Request, res: Response): Promise<void> {
try {
// get product data from request body
const { name, price, description } = req.body;
// create Product object
const product: Product = { name, price, description };
// save product using repository
const savedProduct = await this.productRepository.save(product);
// send 201 response with saved product
res.status(201).json(savedProduct);
} catch (error) {
// log any errors
console.error('error creating product:', error);
// send 500 error response
res.status(500).json({ error: 'failed to save product.' });
}
}
// get /products endpoint
async getProducts(req: Request, res: Response): Promise<void> {
try {
// get products from repository
const products = await this.productRepository.getAll();
// send 200 response with products
res.status(200).json(products);
} catch (error) {
// log any errors
console.error('error fetching products:', error);
// send 500 error response
res.status(500).json({ error: 'failed to fetch products.' });
}
}
}
This code defines a ProductController
with createProduct
and getProducts
methods, interacting with the ProductRepository
to handle HTTP requests for product creation and retrieval.
HTTP Routes
In src/ports/http/routes.ts
, define the HTTP routes and their corresponding handlers :
import express from 'express';
import { ProductController } from './product-controller';
import { ProductRepository } from '../../repository/product-repository';
export const createHttpRouter = (productRepository: ProductRepository): express.Router => {
// router from express
const router = express.Router();
const productController = new ProductController(productRepository);
// route for creating a new product
router.post('/products', productController.createProduct.bind(productController));
// route for fetching all products
router.get('/products', productController.getProducts.bind(productController));
return router;
};
The code exports a function createHttpRouter
that sets up an Express router. It takes a ProductRepository
as input and creates a ProductController
to handle HTTP requests for creating and fetching products. It then defines routes for creating a new product and fetching all products using the controller's methods.
Our App and its Server
In src/app.ts
, create the App class to configure Express and its middlewares :
import express, { Express } from 'express';
import cors from 'cors';
import bodyParser from 'body-parser';
export class App {
// express app instance
private expressApp: Express;
constructor() {
// initialize express app
this.expressApp = express();
// call method to configure middlewares
this.setupMiddlewares();
}
// configure express middlewares
private setupMiddlewares(): void {
// enable CORS for all routes
this.expressApp.use(cors());
// parse application/json
this.expressApp.use(bodyParser.json());
}
// get configured express app instance
public getExpressApp(): Express {
return this.expressApp;
}
}
The code defines an App
class that sets up an Express server with CORS and body-parser middlewares. The class provides a method to get the configured Express app instance. It simplifies the process of creating an Express server with commonly used middlewares for handling JSON data and enabling CORS.
In src/server.ts
, create the Server class to start the application and attach the HTTP routes :
import 'dotenv/config';
import { parseCSVFile } from './adapters/csv/csv-parser';
import { MongoProductRepository } from './adapters/database/mongo';
import { Product } from './domain/product';
import { App } from './app';
import { createHttpRouter } from './ports/http/controllers/routes';
const startServer = async () => {
try {
// read products.csv and parse its data
const products: Product[] = await parseCSVFile('products.csv');
// connect to the MongoDB Atlas database
const productRepository = new MongoProductRepository();
// save the products to the database
for (const product of products) await productRepository.save(product);
// create the Express app and attach the HTTP routes
const app = new App().getExpressApp();
const httpRouter = createHttpRouter(productRepository);
app.use('/api', httpRouter);
// start the server
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`server is running on port ${PORT}`);
});
} catch (error) {
console.error('error loading and saving products:', error);
// terminate the application in case of an error during setup
process.exit(1);
}
};
startServer();
The code in server.ts
sets up an Express server to handle HTTP requests. It reads data from a CSV file named "products.csv," parses it into Product
objects, and saves them to a MongoDB Atlas database. The server listens on the specified port (default 5000) and routes HTTP requests to appropriate controllers for the products API.
Create a new products.csv
file in the root directory and add some product samples. You can use the one below to get started :
name,price,description
Product 1,10,This is product 1
Product 2,15,This is product 2
Product 3,20,This is product 3
Running the Application
Before running the application, add the start
script to your package.json
files :
"scripts": {
"start": "ts-node ./src/server.ts"
}
Run the application using the following command :
# start the server
npm start
If all steps were taken in consideration, you should see get an output like the one below in your terminal :
Testing Endpoints
You can use tools like Postman or CURL
to test the API endpoints.
Adding a product :
- URL:
http://localhost:5000/api/products
- Method: POST
- Body:
{ "name": "New Product", "price": 25, "description": "This is a new product." }
Fetching all products :
- URL:
http://localhost:5000/api/products
- Method: GET
If you use get request method on Postman you should get a JSON response like the one below:
That's it π you now have a simple product management application with the bases of a Hexagonal Architecture !
The project can be downloaded below :
Conclusion π₯
In this guide, we saw how to implement Hexagonal architecture with Node.js, MongoDB, and TypeScript. We defined domain models and business logic in isolation, created ports to abstract external interactions, built adapters to implement the ports, and wired them together in the application layer.
This separation of concerns and loose coupling provides many benefits - the domain can be tested without infrastructure, adapters can be swapped out, and the app is positioned for maintainability and evolution. .