Author: Brendan McLoughlin

  • Hexagonal Architecture with Remix 2 (now React Router Framework)

    If you are a software engineer perhaps one of the situations below sounds familiar.

    You’ve just finished a small change to a landing page, tweaked some layout, added a new field to your article section. You run the tests… and suddenly something deep in your CMS integration is failing.

    Or maybe you want to reuse a bit of business logic in another Remix route, but the function you need is buried inside a loader with HTTP-specific code you can’t untangle.

    Worse, a business partner excitedly tells you they’ve finally signed the contract for that great new CMS vendor everyone’s been raving about. They want to know if it can launch sometime next quarter. Your stomach drops because you know that means touching dozens of files scattered across your app.

    These are all symptoms of the same problem: your core business logic is tangled up with the messy details of HTTP, databases, and third-party APIs.Hexagonal architecture (also called ports and adapters) gives you a way out. It separates your application’s engine from its bodywork, so you can upgrade one without touching the other.

    Note: This post references Remix, which now continues as React Router Framework. The patterns shown here work with both Remix 2 and React Router Framework.

    Principles of Hexagonal Architecture

    The headaches in the intro all have the same root cause: your application’s thinking is mixed in with its doing. Your business logic, the rules and decisions that make your app valuable, is tangled up with the code that talks to HTTP, databases, and third-party APIs. Change one, and you risk breaking the other.

    Hexagonal architecture solves this by moving your application’s “brain” to the center, surrounded by a clear boundary of ports. Everything outside that boundary, databases, APIs, the browsers, connects through adapters. Think of the ports as clearly marked doorways into your app’s core, and the adapters as the translators who stand outside, speaking the language of the outside world but passing through only what the core actually cares about.

    Let’s see this in action.

    At CarGurus, our Sell My Car landing page needs to show recent, relevant content for users. If the CMS is slow or down, we still want the page to load quickly with fallback articles. That requirement led us to create a service function:

    A note on the code: The examples throughout this post are simplified to highlight architectural patterns and are not direct excerpts from our production codebase.

    Here’s where the ports and adapters idea comes in:

    • fetchSellArticles is a port into the application core. Other parts of the system, like a Remix loader, call it when they need to fetch articles.
    • getArticles is another port, used by the core to talk to the CMS. Behind it sits an adapter that knows all the messy details of the CMS API.
    • captureException is a port to our error tracking service.

    In this setup, the application core only knows about its own domain objects (Article) and rules (use recent articles, fall back if needed). It doesn’t know about where the articles come from, what protocol they uses, or how to authenticate, that’s the adapters’ job. This enables easy reuse of the core logic in different contexts because we avoid direct coupling to the shape of a specific CMS. These properties also simplify unit testing. Let’s look at the tests now.

    In Hexagonal architecture, the application core is a safe place for your business rules, protected from the churn of frameworks, protocols, and vendor APIs. Everything messy is kept outside, where adapters can handle it without contaminating your core. This makes it easy to test business rules in your application core without touching a network or database.

    Adapters: translating between your core and the outside world

    Ports give your application core a clean, stable surface to work with, but ports don’t deal with the messy reality of interfacing with the outside world on their own. That’s where adapters come in.

    Adapters live on the other side of your ports. They are the concrete implementation of the port’s interface. Their only job is to translate between your domain model and whatever shape, protocol, or authentication dance the outside world expects. They take a request from the core, make it understandable to the outside system, and return a result the core can use without knowing how it was produced.

    This adapter takes care of:

    • Building a CMS-specific request payload
    • Handling authentication
    • Unwrapping the CMS’s nested response format into a plain Article[]

    From the perspective of the application core, none of that exists. It just calls getArticles with a simple config expressed in terms the business cares about, things like tag and orderBy, and gets back Article objects. The port’s interface speaks the language of the core’s domain and hides details specific to the CMS.

    Why isolate adapters?

    Even if your CMS supports powerful query expressions, resist the urge to expose them through the port. The port should model business concepts. That keeps the application core decoupled and your tests simple; the adapter can translate domain intent into whatever syntax, payload, or authentication the CMS expects.

    Keeping CMS logic in a single adapter means:

    • If the CMS API changes, you update one file.
    • If you switch vendors, you can rewrite the adapter without touching the core or your tests for fetchSellArticles.
    • An API outage or schema change won’t break your business logic tests, only the adapter’s integration test might need attention.

    In other words, the adapter acts as an anti-corruption layer: it absorbs the quirks, inconsistencies, and churn of external systems so your core stays clean, stable, and focused on the work that matters to the business.

    Testing adapters

    Since adapters are the only code that knows about external systems, they’re also the only place where we need slower integration tests. Here we can integrate directly with the CMS system to ensure our systems are working as expected.

    Without business logic our adapter is fairly simple. It doesn’t contain any conditional logic in its behavior. This means we can get away with just a single test to ensure its working as expected.

    From the perspective of the application core, a CMS adapter is just one kind of translator, but it’s not the only one your Remix app needs. Every time your app talks to the outside world, whether it’s through HTTP, WebSockets, a queue, or a browser API, there’s an adapter doing the translation.

    That includes Remix itself.

    Remix as an Adapter

    In a hexagonal architecture, Remix’s loaders and actions are simply another kind of adapter, no different in principle from your CMS or error-tracking adapters. The only difference is what they translate: instead of converting between your domain and a vendor API, they convert between your domain and HTTP itself.

    Here’s what that looks like in practice:

    In the code above, the loader unwraps the HTTP request, validates the region parameter, and calls the fetchSellArticles port in the core. It then wraps the resulting list of recentArticles back into a JSON HTTP response.

    The important thing: the core has no idea this request came from Remix, and Remix doesn’t know anything about how fetchSellArticles actually gets its data.

    Testing the Remix Adapter

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

    Just like with the CMS adapter, we mock the port (fetchSellArticles) so we’re testing only this adapter’s behavior. The pattern is the same: test the translation layer in isolation, not the layers on either side. TypeScript guarantees the fetchSellArticles interface stays in sync and alerts us to any changes in the contract that might break our test or production code.

    Since we have a conditional in this adapter we need 2 tests. The first test ensures the code flows correctly in the happy path and the second test ensures the loader adapter returns a 404 for invalid data. The 404 is an HTTP concern, so it lives in the HTTP adapter. Business rules stay in the application core and protocol rules stay at the edge.

    Seeing the pattern

    By now, you’ve seen this boundary in action twice:

    • When the outside world calls in (a Remix loader, a webhook), the adapter unwraps the request, passes a clean domain value into the core through a port, and wraps the core’s response back into the external format.
    • When the core calls out (fetching from a CMS, sending an email), the adapter takes the domain request, translates it for the external system, and converts the response back into the core’s domain model.

    Same rules in both directions. That’s the beauty of hexagonal architecture, the boundaries and responsibilities never change, which makes the system predictable, testable, and much easier to evolve.

    Pro Tip: Don’t let HTTP envelopes leak into your application core

    One of the easiest ways to erode your adapter boundary in Remix is by passing raw Request or FormData objects straight into the application core. It’s tempting, they already hold the data you need, but this couples your business logic to HTTP and blinds your type checker.

    From TypeScript’s perspective, Request and FormData are opaque containers. The compiler can’t tell what’s inside them, so it can’t help you catch missing fields, invalid formats, or typos in parameter names until runtime. Every time the application core reaches into one of these envelopes, you lose the static guarantees you worked so hard to get.

    You may be building a web app, but your application core doesn’t need to be coupled directly to HTTP. That coupling limits reuse in CLI scripts or background jobs, and it forces you to construct HTTP objects just to run business logic unit tests.

    The fix is simple:

    • Unwrap and validate HTTP data in the Remix adapter.
    • Convert it into explicit domain types (Region, ArticleQuery, UserId, etc.).
    • Pass those domain types through your ports into the application core.

    If your application core needs a Region, it should receive a Region, not a Request it has to dig through. This keeps HTTP concerns out of the core, keeps your ports clean, and lets TypeScript fully enforce correctness across the boundary.

    Bringing it all together

    Those brittle tests, tangled business logic, and stomach-dropping vendor changes from the start of this post?
    Hexagonal architecture is how you prevent them from taking over your life as a Remix developer.

    By putting your application core, the rules and decisions your business cares about, in the center, and surrounding it with clearly defined ports, you create a safe, stable space for the logic that matters most. By pushing all framework, protocol, and vendor-specific code into adapters at the edges, you keep those details from leaking inward and complicating your core.

    Whether the call flows into the application core (a Remix loader, a webhook) or out of it (fetching from a CMS, sending an email), the pattern is the same: unwrap external details at the edge, work in your domain language in the center, then wrap results back for the outside world.

    Once you start seeing Remix loaders and actions as just another kind of adapter, you’ll stop worrying about whether a change will ripple unpredictably through your app. You’ll know exactly where to look, what to change, and what to test.

  • VINs: The Encoding Stamped Into Steel

    Do I have a favorite standard? Silly question. How could I not? There are so many great ones, each with its own weirdly fascinating story.

    In a previous job, I spent a lot of time getting to know ECMA-262 (the JavaScript language specification). Lately, though, I’ve been acquiring a taste for encoding standards. Encodings are fascinating. They’re all about constraints and information density. How much can you pack into a fixed space? What can you safely leave out? and what existing patterns do you need to work around?

    Of course, encodings only work if everyone agrees to use them. Check out the later chapters of Jing Tsu’s excellent Kingdom of Characters for a reminder that even a clever, seemingly-dry technical standard like Unicode can turn into a messy game of politics the moment the whole world has to agree.

    But in the automotive world, it’s hard to beat ISO 3779: the international standard for the humble Vehicle Identification Number (VIN). If anyone ever writes the tell-all book about how that standard came to be, I’ll be the first to preorder it.

    Because a VIN is so much more than a serial number. It’s an encoding: a compact little artifact that carries pieces of a vehicle’s manufacturing story. Why would we want that? Let me tell you a story…

    Alice and the bicycle shop

    Imagine a small town with a local bicycle shop. The shop owner, Alice, makes custom bikes. Alice is proud of the quality of her work and decides to offer a 1 year warranty on her bikes. To accomplish this she needs to keep track of her bikes to know if they are still under warranty. At first, she only needs to keep track of a few bikes at a time, so she uses a simple numbering system: Bike 1, Bike 2, Bike 3, and so on.

    As Alice’s business grows, she starts making different types of bikes: mountain bikes, road bikes, and city bikes. She also begins to source parts from various suppliers. She grows tired of looking up bike numbers from her record to understand what they are and when they were manufactured. So she creates a more sophisticated system to track not just the number of bikes, but also their types, components, and manufacturing dates.

    Alice develops a new identification system. Each bike now gets a 10-character code:

    • 2 letters for the bike type (MB for mountain bike, RB for road bike, CB for city bike)
    • 4 numbers for the date of manufacture (MMYY)
    • 4 numbers for the sequential production number

    So, a mountain bike made in March 2023, being the 15th bike of that type, would be: MB032300015.

    A month later, a customer rolls in with a bike whose basket has come loose. Alice reads the tag: CB062500128. She immediately knows it’s a city bike built in June 2025, still within warranty, and later she can pull the exact build sheet to see which basket it shipped with. Alice still needs her records, but the code gives her enough context to answer the warranty question without flipping through paperwork.

    Alice isn’t alone in discovering this pattern. Serial numbers for high-value manufactured products are common across industries, from power tools to medical devices to industrial equipment. They make it easier to trace inventory, manage warranties and service history, fight counterfeits, support theft recovery, and do targeted recalls when something goes wrong.

    From serial numbers to VINs

    In the automotive world, serial numbers evolved into something more sophisticated: the VIN (vehicle identification number). In the United States, we follow ISO 3779’s structure, but U.S. regulations are more prescriptive about how some of those fields are used. And that’s where VINs get fun: they’re not just serial numbers, they’re a shared contract, something thousands of independent companies can read, type, validate, and exchange without needing a central database.

    A VIN is a 17-character code, broken into sections:

    • Positions 1-3 (World Manufacturer Identifier): Identifies the manufacturer, and the first character is commonly used as a country/region-of-origin indicator. For example, vehicles with VINs starting with 1/4/5 are associated with the United States, while J is Japan.
    • Positions 4-8 (Vehicle Descriptor Section): Platform, model, body style, and often the engine type when multiple options exist.
    • Position 9: Check digit (more on this in a moment)
    • Position 10: Model year
    • Position 11: Assembly plant code
    • Positions 12-17: Sequential production number for that model at that plant in that year

    Designed for the real world

    The letters O (o), I (i), and Q (q) never appear in VINs. VINs get handwritten on insurance forms, read aloud over phone calls to DMVs, transcribed from photos of dashboard plates. By excluding characters that are easily confused with numerals (I/1, O/0, Q/9), the designers eliminated an entire class of transcription errors. The VIN standard prioritizes surviving the messy real world.

    The check digit

    Position 9 is a check digit, calculated using a weighted sum algorithm (mod 11). Each position in the VIN has an assigned weight, letters using a translation table from the standard, and the remainder modulo 11 becomes the check digit with the value of 10 represented as X.

    This helps detect the most common errors when copying vins such as single-character errors or transpositions. If someone mistypes one digit or swaps two adjacent characters, the check digit won’t match. The VIN itself tells you it’s wrong without requiring a database in the loop. This is the same pattern found in credit card numbers (Luhn algorithm) and ISBNs.

    Interestingly, ISO 3779 doesn’t require the check digit. However, it’s mandatory in the US, but far less common in European VINs.

    The model year trade-off

    Position 10 encodes the model year using a single character that cycles through letters and numbers. The “epoch” is 1980 (when the 17-character VIN became standard), so A = 1980, B = 1981, and so on. But if you paid attention in your information theory class you may have noticed a problem. With only 30 usable characters (remember, I, O, Q are excluded), it’s impossible to encode every year. A meant 1980… and then meant 2010… and it will mean 2040.

    This is common encoding trade-off: one character keeps the VIN compact and fixed-width, but you need context to disambiguate. In practice, this doesn’t cause too much trouble. The VDS section provides context (a 2010 Camry vs. a 1980 Camry have different model codes), and wear, regulations, and inspection requirements mean that its rare to find a 30+ year-old vehicle on the road.

    Plants and sequences

    The 11th digit identifies the assembly plant, but there’s no global registry. Manufacturers define their own plant codes, which gives the system flexibility as plants open, close, and get reassigned. This helps the standard avoid becoming a bottleneck, but the trade-off is that you need manufacturer-specific lookups to interpret this position.

    The last six positions (12-17) hold the sequential production number. A theoretical max of one million vehicles per model/plant/year. That may sound like plenty, but Ford has the capacity to make over 700,000 F-150s in a year. Fortunately, most high-volume models are built across multiple assembly plants, each with its own plant code and production sequence.

    What’s NOT in the VIN

    As much as I’d love for the VIN to encode more, there are some things it deliberately leaves out. Trim level, color, options packages, or title status.

    The VIN is designed to be immutable. It is literally stamped into the frame, recorded in government databases, referenced in insurance policies for decades. Trim and options aren’t always finalized at VIN assignment time. Color can change at a body shop and a vehicle’s title status (accidents, salvage, theft recovery) changes on a long enough timeline. Despite all that, the VIN stays stable, acting as a globally unique key that other systems build on.

    How CarGurus Operationalizes VINs

    At CarGurus, the VIN is more than a technical detail, it’s core to how we make vehicle listings comparable and trustworthy. We require a VIN for every listing because it’s the best way to uniquely identify a specific vehicle and connect it to the information shoppers care about.

    Filling in what the VIN leaves out: trim

    Trim level isn’t encoded in the VIN, but it matters. A base-model Honda Accord and a fully-loaded Touring trim can differ by $10,000+ in value. Buyers care deeply about this distinction.

    To close that gap, we use our historical listing data to infer trim when we’ve seen that VIN before. Trim is typically stable for a given vehicle over its life (unlike things like title status), so it’s a great example of “not in the VIN, but still knowable.”

    Vehicle history: lifecycle facts that change over time

    Title and damage history are another deliberate omission. A VIN encodes the vehicle as it left the factory, but accidents, theft recovery, flood damage, and salvage branding happen after manufacturing. These events can slash resale value by 20–50%, but they aren’t part of the VIN because they can change over a vehicle’s lifetime.

    When title defects occur, they’re reported to state DMVs, NMVTIS, and other databases, all using the VIN as their primary key. That’s what enables coordination across this fragmented landscape. Third-party aggregators compile the data, and at CarGurus, we partner with these providers to ensure our listings reflect a vehicle’s history as accurately as possible.

    Online marketplaces didn’t exist when the first edition of ISO 3779 was published in 1976. But the VIN gives us what standards do best: a stable foundation that enables coordination at scale. We build the rest from there.