The Secret to Maintainable Remix Apps: Hexagonal Architecture

The Secret to Maintainable Remix Apps: Hexagonal Architecture
Photo credit

Ever feel like updating your Remix app's UI is a circus act? One small change, and suddenly everything's breaking? You're not alone. This fragility often comes from tightly coupled components – a change in one place triggers a domino effect throughout your code.

But there's a solution: hexagonal architecture. This pattern, also known as ports and adapters, helps you build Remix apps that are flexible, maintainable, and resilient to change. How? By clearly separating your core business logic from the nitty-gritty details of UI components, databases, and external services.

With hexagonal architecture, you'll be able to:

  • Make UI changes with confidence: No more worrying about breaking unrelated parts of your app.
  • Test components in isolation: Simplify your testing process and catch bugs early.
  • Swap out dependencies easily: Keep your app up-to-date without major refactoring.
  • Reuse your code like Lego blocks: Build a modular system where components can be easily repurposed.

Principles of Hexagonal Architecture

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, the six-sided shape isn't inherently meaningful[2]. What's important is that ports are simply interfaces acting as a boundary between the core and the adapters. The interfaces exposed by ports should match the domain model of your application core and encapsulate 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 to achieve this, we need to interface with a couple of ports. The first port invokes our business logic and passes in the username as a value. In our example, this port is the function signature. We also leverage a port to fetch 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 enables easy reuse of 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 simplify unit testing. Let's look at the tests 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 promotes decoupling 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 painless to create a test mock along this boundary.

Adapters

We've seen how ports help 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 converts the responses back into domain objects that the core system can understand. Adapters encapsulate logic and decisions specific to the external dependency/protocol, but they should not 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 hiding the complexities of talking to the GitHub API, providing authentication, and unwrapping the response envelope. Since it integrates with an external service, we want an integration test for this component. Integration tests are painful to work with because they can be slow to execute and depend on 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.

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[0].description).toBe(
      "Some common .gitignore configurations",
    );
  });
});

Tightly focused adapters are not just important for testing they are also are much easier to maintain and evolve over time. If the external system changes, you only need to update the relevant adapter. Its also easy to swap out adapters if needed. For example, if we decide to switch from GitHub's GraphQL interface to its REST API for fetching gist, we could re-write this adapter without impacting the rest of the application.

The Role of Remix in Hexagonal Architecture

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 the Remix Adapter

Testing this adapter is fairly straightforward using the same outside-in approach we used for the application core.

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 not other parts of the system. TypeScript guarantees the topGistForUser interface stays in sync and alerts us to any changes in the contract that might break our test or production code.

Conclusion

The hexagonal architecture, with its emphasis on ports and adapters, offers several advantages for developing a maintainable Remix application. Think of ports as walls between different parts of your app. They keep the messy details of how you talk to external systems separate from the core logic. This means you can swap out those external parts without tearing your whole app apart. 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. Let's take a closer look at how to implement these principles effectively within your Remix application.

  • Remix is the Adapter: Your Remix loaders and actions are your HTTP translators. Keep them lean, focused on request/response handling, and let your core business logic shine elsewhere.
  • Unwrap and Conquer: Before you pass data into the heart of your application, take the time to unwrap those Request and FormData objects. Your domain objects will thank you, and so will your type checker.
  • Keep Adapters Pure: Think of your client adapters like diplomats: they facilitate communication, but they don't make policy decisions. Leave the business logic to your core domain.
  • Test with Confidence: Embrace the test seams that ports provide. By mocking dependencies at these boundaries, you unlock the power of isolated, focused unit tests.

Additional Resources

Alistair Cockburn's writings

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.

AWS prescriptive guidance

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. Experiment 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.

Subscribe to Laconic Wit

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe