n.

WTF is... Ports and Adapters?

One of the first patterns you will likely encounter in Software Engineering is Ports & Adapters, also known as Hexagonal Architecture. I remember when I first encountered it, I read a lot of articles, and still struggled to grasp what exactly was a Port and what was an Adapter. I think the main issue for me was the word "Port".

What's the problem?

First of all, let's establish the problem we're trying to solve. The simplest way for an application to make use of a depdency is to just integrate it directly into our application code.

The jagged line between the Application box and the Dependency box represents the uniqe shape of the dependency's interface. It shows how we can easily tailor our app to fit the dependency's needs.

The trouble is, swapping the dependency out for another library is going to be a nightmare. We'll have to pick through our application code to find all of the library-specific references. For example, maybe this dependency has a get method, but another uses fetch to do a similar task.

All of this means that our application is closely-coupled to the dependency.

Ports

What we'd like to do is create a standard interface that our application code can depend on, removing all of the dependency-specific nonsense from our app. That's exactly what a port is. It's an interface through which we'd like our app to communicate with the outside world.

Let's say we're creating an app that can retrieve and save users to a database.

First of all, let's establish what a User is:

type User = {
  id: string;
  name: string;
  age: number;
};

Simple enough. Now we want to be able to persist our User to some sort of database. The database technology doesn't matter, it could be anything, we could even save it to disk. The important thing is, we want to keep any dependency-specific code out of our application, and make switching out the dependency as clean as possible.

The simplest way to do this, is to define an interface:

interface UserPort = {
  get: (id: string) => Promise<User | null>
  save: (user: User) => Promise<void>
}

In the above example, we've established that whatever technology we choose, our application will interact with it via 2 methods, get and save. We now have something that looks like this:

As the diagram demonstrates, we're still missing something. We have a clean, standardised interface but it doesn't match that of our dependency. Now we need to adapt the dependency's interface to fit ours.

Adapters

The final piece of the puzzle is the Adapter. This is where we'll take the dependency's interface and transform it to comply with our port.

Now if we want to change out the dependency, all we have to do is write a new adapter for that specific library, and we don't have to pick through application code.

Keeping with our User app, here's an example adapter that will get and set users in a DynamoDB table:

The following snippets contain pseudo-code to keep it brief, but you get the idea...

class DynamoUserAdapter implements UserPort {
  async get(id: string): Promise<User | null> {
    const user = await dynamoDB
      .get({ TableName: "UsersTable", Key: { id } })
      .promise();
    return user.Item as User;
  }

  async save(user: User): Promise<void> {
    await dynamoDB.put({ TableName: "UsersTable", Item: user }).promise();
  }
}

And just to really hammer the point home, let's say we suddenly can't afford DynamoDB anymore, so we decide to start writing all of our user data to disk, we could swap it our for Node's fs module:

class FileSystemUserAdapter implements UserPort {
  async get(id: string): Promise<User | null> {
    const user = await fs.promises.readFile(`./users/${id}.json`, "utf-8");
    return JSON.parse(user) as User;
  }

  async save(user: User): Promise<void> {
    await fs.promises.writeFile(
      `./users/${user.id}.json`,
      JSON.stringify(user)
    );
  }
}

And we haven't had to touch the rest of our app.

Hexagonal Architecture

As mentioned earlier, another name for this pattern is Hexagonal Architecture. Hexagonal architecture allows us to demonstrate Ports and Adapters with a more holistic view. The hexagon itself has no real significance. It does however, show how the dependencies sit on the "outside" of the application, and all of our important business logic sits on the inside.

This view of the pattern also gives rise to the two types of adapters:

  • Driving - Triggers our application to execute (HTTP Request, CLI, Cron trigger)
  • Driven - Is triggered by our application (Database, Blob storage, Push notification)

Driving adapters tend to be shown on the left of the diagram, and dirven adapters on the right.

Next Steps

You may have noticed that although we have a pluggable way of storing and retrieving our user data, there's still a risk of our application being littered with mentions of DynamoUserAdapter or imports like ~/adapters/fileSystemUserAdapter.

One way to fix this is through Dependency Inversion, but that's a topic for another article.