Michal Zalecki
Michal Zalecki
software development, testing, JavaScript,
TypeScript, Node.js, React, and other stuff

Elegant Frontend Architecture

Software design and architecture is not something you can install with npm. If you disagree, nothing is stopping you from installing an opinionated JavaScript framework that can solve all your problems with a CLI generator. If you are not a fan (or believer) of such solutions, keep reading as you might find this post interesting.

My goal was to put in writing an opinionated guide on how to structure a modern frontend application. The approach is inspired by well-known architectural patterns. Instead of trying to come up with a new exciting solution on how to organize code in my frontend applications, I wanted to explore how other communities solved similar problems. This is what I came up with. For the lack of a better name, I called it Elegant Frontend Architecture (EFA). It applies to any modern frontend framework.

This article is in a working draft stage.

Past learnings

Frontend development has come a long way since the prime time of jQuery. Frameworks that came after tried and are still trying to explore new, better ways to create frontend applications. It's exciting to be a part of such a vibrant community that innovates all the time. The downside is that what we take for innovation is sometimes (not always, but sometimes) just reinventing the wheel.

From the procedural style, we moved to a more object-oriented paradigm and explored different MVC/MVVM frameworks. Unit testing frontend code became a thing. After noticing problems with enormous controllers, we wanted to create smaller independent components. We also discovered that immutability is a good idea and adopted simple event-based abstractions like Redux. We also started considering micro frontends to make building one app by independent teams easier.

This gives me the impression that the JavaScript community is playing design patterns and architecture catch-up when you compare it to more mature technologies. Why is that? I think there are multiple factors. One is a popularity contest driven by GitHub stars. New shiny stuff casts a shadow over learnings from past experiences of other communities. But to be fair, there is another, more important reason. It takes time to create a conceptual translation of the initial idea, which is often best suited for backend application, and prove its usefulness on the frontend. This is why the JavaScript community arrives with similar (on a high level) solutions much later.

It's important to remember that you can't just directly apply many architectural patterns to UI applications (frontend or mobile). The architecture is different just by the fact the nature of the UI application is different from the server application. It still doesn't take away from the general idea. My only request is that you approach it with an open mind.

Layered architecture (top level)

We're going to reason about this architecture on three different levels: top, middle, and low. Each level is based on a different architectural pattern as it aims at solving a different issue. Those levels loosely correspond to the depth of the files in the directories tree.

Let's start with the first, top level.

$ tree -L 1 src

src
├── application
├── domain
├── ui
├── infrastructure
└── utils

Those four directories (without utils) create layers of Layered Architecture which is typical for Domain-Driven Design. Depending on your experience you might have heard about DDD or you are even a practitioner. DDD is a very broad and complex topic in and of itself. Don't worry if it's new to you. I don't intend this frontend architecture to require the understanding of DDD to be useful. I will do my best to give you a minimal concise explanation of certain aspects so it all makes sense.

Despite the same layer names, EFA layers are fundamentally different from typical DDD layers. The main reason is to better suit how frontend applications are structured. First, I will explain what the layer does in EFA and then give you a brief explanation of what it does in a typical DDD. You will quickly see why I deviated from the typical approach.

Before diving into details about each layer, I would like you to understand the relationship between layers. The most important rule is that a layer shouldn't be aware of any layers above it. This is expressed by the direction of arrows and understanding it is fundamental. Those are a few ways to understand what relation A -> B means:

  • A has reference of B
  • A knows about B
  • A can import from B (B can't import from A)
  • A is looking into B

That means that for EFA, the Infrastructure Layer is shared across all other layers. This is where the similarity to the typical DDD approach ends. In EFA Application Layer can reference code in the Domain Layer and UI Layer (still a layer below). Domain Layer can reference code in the UI Layer. UI Layer is limited only to importing code from the shared Infrastructure Layer.

UI Layer

Let's start with the UI Layer. The UI Layer also called Presentation or Interface Layer is going to host all reusable, presentational components. If you are familiar with the idea of Atomic Design, the UI layer is where you put atoms. We're talking about components like Button, Table, Input. UI Layer is where you put your design system components.

You must keep those components simple and easy to reuse. UI Layer components have to be agnostic from any business requirements specific to any particular page in your application. This is also why they shouldn't be making any HTTP calls and depend on rendering context.

$ tree -L 1 src/ui

src/ui
├── Button
├── Input
└── Table

In a typical Layered Architecture, the User Interface Layer is responsible for communicating with a frontend or mobile application. You could find modules like Controllers, Presenters, Serializers, or GraphQL Resolvers. You can see how this is very different and not suited for frontend applications.

Domain Layer

In EFA, Domain Layer code is responsible for supporting business domains. I will expand on the structure of the domain code while discussing the middle EFA level. For now, you need to know that public components in the Domain Layer are container components which are major building blocks of the entire page. Those "public" components are the only modules that can be imported by the Application Layer.

$ tree -L 1 src/domain

src/domain
├── customers
├── orders
└── products

This is an example of business domains you could find in an e-commerce system.

Duplication in Domain Layer

Domain should be focused and don't reach to other domains. Importing code from one domain by another domain shouldn't take place. If that's the case in your application, then you might incorrectly identify domain boundaries. This doesn't have to be the case, and domains might be correct and you may choose a different solution:

#1 Both domains require the same presentational component.

Your products list (products domain) and shopping cart (orders domain) may want to use the same component to display product image. Solution: Move the common component to UI Layer.

#2 Both domains require the same container components (fetching data + presentation).

Your products list (products domain) and shopping cart (orders domain) may want to use the same component to display suggested products. Solution: You presentational component has too much responsibility. Split your component into smaller container components. You will use them as building blocks in the Application Layer.

#3 Both domains make the same API call.

You fetch user information in account settings (customers domain) and while placing an order (orders domain). Solution: Prefer duplication over the wrong abstraction. (and watch this talk). You might get away with REST and placing your endpoints-aware REST client in Infrastructure Layer, but in case you use purpose-built GraphQL queries for your components, just keep on doing that. GraphQL exists to enable you to do exactly that.

#4 Both domains need to use the same helper function or hook.

When you're confident that this case is not fulfilled by the Infrastructure Layer, you might just place a function under top-level utils.

Business Logic and Frontend

Since public components in the Domain Layer are major building blocks of the application, they naturally will be composed of smaller components. This is why the Domain Layer imports presentational components from the UI layer.

This is a substantial deviation from the idea of the Domain Layer in DDD. Typical Domain Layer is responsible for implementing pure business logic, agnostic from other layers and frameworks. This is because the place for business logic in the application is the backend. Otherwise, it's just leaky abstraction in your API.

Since I argue there's no true business logic on the frontend, do we even need a Domain Layer? Surprisingly, the high-level structure of the EFA Domain Layer and DDD Domain Layer will be similar. There's a good chance that your business logic needs a user interface to either accept or present the information. This is why at the beginning I've mentioned that code in EFA Domain Later is supporting the business domain.

If you want to apply a strict Layered Architecture to your frontend code, you could take all components and move them under the Application Layer. I've experimented with this approach and ultimately didn't find it very beneficial.

The strict approach left me with a thin Domain Layer, only containing types. Since there's close to none business logic on FE, there was not much left to fit the typical Domain Layer. In a grand scheme of things, the frontend is a presentation layer for the entire application, and it's not a place for the domain. This doesn't mean there's no business complexity, but the one that is doesn't fall under typical Layered Architecture. It still can have layers, just different ones.

Application Layer

In EFA, Application Layer is the glue code that puts together the entire application and contains components at the top of your components tree. It defines routing, builds pages from presentational components, and populates common context (eg. theme, store) for the rest of your app.

$ tree -L 2 src/application

src/application
├── App.tsx
└── pages
    ├── CheckoutPage.tsx
    ├── LoginPage.tsx
    └── ProductPage.tsx

Application Layer is instrumental in keeping the subdomains' scope narrow. Pages in our applications cross multiple domain boundaries. It's not uncommon for an online banking application to have a dashboard that will show you your savings accounts list, recent transactions, and your next mortgage payment. Those are all different domains for the banking application, but with a page component in the Application Layer, you can compose container components into one page without crossing any boundaries.

Infrastructure Layer

In EFA, the Infrastructure Layer is responsible for providing different abstractions to reduce the coupling of other layers with specific implementation.

Infrastructure modules can provide an additional layer between the APIs you don't control by wrapping them in the API you do control. Examples are A/B testing tools, error reporting, loggers, analytics tools, etc. Other layers can import those modules instead of interacting with external services directly. It will make updating or even replacing them less of a headache.

The Infrastructure Layer is not limited to external services. It's an excellent place for configuring your application. Define a theme for your styled-components, add middleware to your REST or GraphQL client instance, or register a ServiceWorker.

It's a good idea to also create wrappers for low-level Web APIs that provide universal access to various browser features, might not be best suited to use directly. In another article, I've described why using LocalStorage directly is a bad idea. Such wrapper that extends native API with additional safety net belongs in the Infrastructure Layer. Another great candidate for the Infrastructure Layer module is a Facade for complex APIs like Web Bluetooth API. Even simple APIs can be difficult to test reliably. Think about Date (time zones) or process.env. To avoid mutating global objects in tests, you could create another Facade, even if it's just to make it possible to use them with dependency injection or automated mocks.

$ tree -L 2 src/infrastructure

src/infrastructure
├── persistance
│   ├── graphql.ts
│   └── storage.ts
├── services
│   ├── experiments.ts
│   ├── monitor.ts
│   └── tracking.ts
├── sw.ts
└── theme.ts

You may be wondering how Infrastructure Layer is different from typical "utils" in the source root of your project. I think about utility functions as something you wish would be a part of JavaScript's standard library. While it's clear that functions like clone(object) or range(min, max) belong in some helpers directory, sometimes it might get blurry. Strings formatting might be your custom implementation or wrapper around built-in Intl. It's up to you what approach makes more sense to you.

In DDD, the Infrastructure Layer is responsible for communication between layers, providing persistence options, implementing repositories, or communicating with external systems. In the grand scheme of things, it's not much different from how we use it in EFA.

Subdomains (middle level)

TODO

Package scope (low level)

TODO

Repository

MichalZalecki/elegant-frontend-architecture

Conclusion

With many rules to follow, the most important is to be pragmatic so you can break the rules when needed. Elegant Frontend Architecture is not a framework, but a bunch of malleable ideas, so make this architecture your own.

Photo by David Kovalenko on Unsplash.