Skip to content

Instantly share code, notes, and snippets.

@bmac

bmac/blog.md Secret

Last active June 19, 2024 21:19
Show Gist options
  • Save bmac/3f1b213424786ed294c0f3e1307e1b5f to your computer and use it in GitHub Desktop.
Save bmac/3f1b213424786ed294c0f3e1307e1b5f to your computer and use it in GitHub Desktop.

The Secret to Maintainable Remix Apps: Hexagonal Architecture

Dr. Alistair Cockburn's hexagonal architecture pattern, also known as the ports and adapters pattern, is a tool for structuring software applications in a way that reduces coupling of components by breaking the system down into core business logic and adapters that interface with the outside world. The application core and adapters communicate via "ports" or interfaces that match the core domain of the application.

By following a hexagonal architecture approach, you can make the backend code of your Remix application more modular and maintainable. This modularity simplifies testing, allowing you to isolate and verify individual components with ease. It also facilitates updating dependencies, as the loosely coupled architecture ensures that changes in one part of the system don't ripple through the entire codebase. Moreover, it enhances the reusability of software components, enabling you to repurpose them across different parts of your application or even in different projects.

Principles of Hexagonal Architecture

wikipedia image

Hexagonal architecture is usually depicted using a similar image to the one above[1]. In the center, we have application code. This is where your business logic lives. A well-structured application will use Domain-driven design to model and build a software system that matches the business problem. In hexagonal architecture, this DDD-inspired system lives within the application core, where it can focus on implementing business logic without having to deal with the idiosyncrasies of outside systems or dependencies.

Surrounding the application core is a solid hexagon-shaped border called "ports." The application core communicates with external systems by passing messages or value objects across the ports. Despite the name, there isn't actually any meaning to the six-sided shape[2]. What is important here is that ports are just interfaces and act as a boundary between the core and the adapters. The interfaces exposed by ports should match the domain model of your application core and do their best to hide the implementation details of the external systems.

Adapters live on the other side of the "ports." They are the concrete implementations of the port interface. Their job is to convert a domain object into a message on some external protocol and generate a new domain object from the response.

Application Core

This is all a bit abstract. Let's look at a concrete example. Imagine our application, jester.codes, needs to show a list of the top 10 gists for a user ordered by the number of stars each gist has. In this scenario, our application core might look something like this:

export const topGistsForUser = async (username: string) => {
  const gists = await getGistsForUser(username);
  return gists
    .sort((a, b) => {
      return b.stargazerCount - a.stargazerCount;
    }).slice(0, 10);
};

The core of our business logic is sorting a list of gists and taking the first 10. But in order to achieve this, we need to interface with a couple of ports. The first port calls into our business logic and passes in the username as a value. In our example, this port is the function signature. We also call out to a port to retrieve the list of gists for the user via the getGistsForUser interface.

                               // port
export const topGistsForUser = async (username: string) => {
                   // port
  const gists = await getGistsForUser(username);
  // business logic
  return gists
    .sort((a, b) => {
      return b.stargazerCount - a.stargazerCount;
    }).slice(0, 10);
};

Since this function just accepts a username as the input and returns a list of Gists as the output, its logic and behavior are only limited to our application domain. This makes it easy to reuse the core logic in different contexts because we don't have any direct coupling to any one specific Remix loader or action. These properties also make it easy to test. Let's look at those now.

describe("gistService", () => {
  it("should return sorted gists", async () => {
    const oneStarGist = stub<Gist>({ stargazerCount: 1 });
    const threeStarGist = stub<Gist>({ stargazerCount: 3 });
    const fiveStarGist = stub<Gist>({ stargazerCount: 5 });
    vi.spyOn(githubClient, "getGistsForUser").mockResolvedValue([
      threeStarGist,
      fiveStarGist,
      oneStarGist,
    ]);

    const topGists = await gistService.topGistForUser("octocat");

    expect(topGists[0]).toBe(fiveStarGist);
    expect(topGists[1]).toBe(threeStarGist);
    expect(topGists[2]).toBe(oneStarGist);
  });
  
  it("returns at most 10 gists", async () => {
    const aDozenGists = Array.from({length: 12}).map(() => stub<Gist>({}));
    vi.spyOn(githubClient, "getGistsForUser").mockResolvedValue(aDozenGists);

    const topGists = await gistService.topGistForUser("octocat");

    expect(topGists.length).toBe(10);
  });
});

We have two tests for our core logic. The first test ensures we are sorting our gists correctly, and our second test asserts that we only return the first 10 results.

We can use an outside-in approach to testing and mock out our external dependency at the getGistsForUser port. Hexagonal architecture encourages loose coupling because our application core is only coupled to the interface exposed by the port and not the implementation-specific details of interfacing with GitHub directly. This makes it relatively painless to create a test mock along this boundary.

Adapers

We've seen how ports help to hide the complexities of external systems from our core application logic. But experience tells us that complexity has to live somewhere. Only toy software systems can remain blissfully ignorant of the outside world. In hexagonal architecture, these complexities live in adapters. An adapter's job is to translate our domain objects into an external protocol so our system can interact with the outside world. It also needs to translate the responses back into domain objects that the core system can understand. They may contain logic and decisions specific to the external dependency/protocol, but they shouldn't perform any business logic.

import { graphql } from "@octokit/graphql";

export const getGistsForUser = async (username: string) => {
  const response = await graphql<GistResponse>(`<long graphql string>`,
    {
      headers: {
        authorization: `token ${process.env.GITHUB_TOKEN}`,
      },
    },
  );

  return response.user.gists.nodes;
};

Our adapter implementation above conforms to the getGistsForUser port while encapsulating the complexities of talking to the GitHub API, providing authentication, and unwrapping the response envelope. Since it integrates with an external service, we probably want to write an integration test for this component. Integration tests are usually painful to work with because they can be slow and depend on some external state. But since this adapter is relatively limited in scope and our core business logic lives outside of the adapter, we can get away with just a single test for this behavior.

ts

import { getGistsForUser } from "./githubClient";

describe("githubClient", () => {
  it("it should fetch gits from the github api", async () => {
    const gists = await getGistsForUser("octocat");
    expect(gists).toHaveLength(8);
    expect(gists[4].description).toBe(
      "Some common .gitignore configurations",
    );
  });
});

Remix

We are four sections into this blog post, and we've barely talked about Remix. Where does it fit into this architecture?

The secret is Remix is just another adapter for our core business logic. Its job is to translate incoming HTTP requests into domain objects for our system and translate the resulting entities into HTTP responses for the browser.

This job primarily falls on the loader/action functions in a Remix app. So let's take a look at one now.

export async function loader({ params }: LoaderFunctionArgs) {
  try {
    const topGists = topGistForUser(params.username || "");
    return json({
      topGists: await topGists,
    });
  } catch (error) {
    throw json({}, 404);
  }
}

In the code above, the Remix loader works as an adapter by grabbing the username out of the incoming request and passing it into our core application logic via the topGistForUser port. It also takes the resulting list of top gists and transforms it into an HTTP response object that serializes our data as JSON. In some cases, it might also be responsible for some error handling, transforming our domain exception into an HTTP 404 page.

Pro Tip: In a well-structured Remix app that follows hexagonal architecture principles, the loader/action layer is the only part of your codebase that should be touching the incoming Request and outgoing Response. You will want to unwrap any data from this request and transform it into a more concrete domain object before passing it down into your core application. If your port interface functions take a Request or FormData object as a parameter, that is usually a sign you are leaking some of your adapter implementation details into your core app logic.

Additionally, since Request and FormData are envelopes that can hold lots of different data, passing them around between different layers of the system means losing an opportunity for TypeScript to enforce correctness in our system. TypeScript usually doesn't know what kind of data they hold in the body. By unwrapping these objects early and converting them into well-defined types, you can leverage TypeScript's type-checking capabilities to ensure data integrity and consistency throughout your application.

Testing this adapter is fairly straightforward using the outside-in approach.

describe("loader", () => {
  it("should return the top gists for the username", async () => {
    const gists = [stub<Gist>({ id: "gist" })];
    vi.spyOn(gistService, "topGistForUser").mockResolvedValue(gists);
    const response = await loader(stub<LoaderFunctionArgs>({
      params: { username: "octocat" }
    }));

    const data = await response.json();

    expect(data.topGists).toEqual(gists);
  });

  it("should reject with a 404 if the user name is invalid", async () => {
    vi.spyOn(gistService, "topGistForUser").mockRejectedValue(
      new Error("no such user"),
    );

    const response = await loader(stub<LoaderFunctionArgs>({
      params: { username: "no_such_user" }
    }));

    expect(response).rejects.toMatchObject({
      status: 404,
    });
  });
});

Once again, we can mock out our dependencies on the core application logic at the topGistForUser port. This lets us focus on testing only the behavior of this adapter and keeps our coupling low. TypeScript will ensure the topGistForUser interface stays in sync and let us know if it changes in a way that breaks our test or production code.

Benefits of this approach

The hexagonal architecture, with its emphasis on ports and adapters, offers several advantages for developing a maintainable Remix application. By structuring your codebase around ports, you can isolate the implementation details of adapters from both each other and the core business logic. This isolation significantly simplifies the process of evolving the user interface, as UI changes can be made without the need to rewrite the underlying domain logic. Additionally, updating dependencies becomes more manageable because they are confined to isolated adapters rather than being spread across the entire application. The use of ports also provides excellent test seams, allowing for fast and isolated unit tests that ensure the reliability and stability of your codebase.

Additional Resources

Both the user-side and the server-side problems actually are caused by the same error in design and programming — the entanglement between the business logic and the interaction with external entities. The asymmetry to exploit is not that between ‘’left’’ and ‘’right’’ sides of the application but between ‘’inside’’ and ‘’outside’’ of the application. The rule to obey is that code pertaining to the ‘’inside’’ part should not leak into the ‘’outside’’ part.

Use the hexagonal architecture pattern when:

  • You want to decouple your application architecture to create components that can be fully tested.
  • Multiple types of clients can use the same domain logic.
  • Your UI and database components require periodical technology refreshes that don't affect application logic.

Jester

The example remix project reference by this blogpost.

Call to Action

Adopting a hexagonal architecture in your Remix applications can transform the way you build, test, and maintain your software. By decoupling your core business logic from external dependencies, you create a more modular, maintainable, and testable codebase. I encourage you to start experimenting with this architecture pattern in your projects. Explore the example project Jester to see these principles in action, and check out the source code for more insights. If you have any questions or want to share your experiences, feel free to leave a comment below or reach out on social media. Let's build better websites!

[1] https://en.wikipedia.org/wiki/Hexagonal_architecture_(software)#/media/File:Hexagonal_Architecture.svg [2] It was just picked so the author could leave enough space to represent the different interfaces needed between the component and the external world.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment