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, and this is what I came up with. For the lack of a better name, I called it Elegant Frontend Architecture. EFA for short. Yes, I find it elegant indeed. It should apply to any modern frontend framework, so maybe it's not that opinionated after all.
I've prepared a repository with a reference project structure (without the concrete implementation). Use it to get a full picture and follow the article for a detailed explanation.
Table of contents
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.
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 because 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.
We're going to reason about this architecture on three different levels: top, middle, and low. Each level is inspired by a different architectural pattern as it aims at solving a different issue. Those architecture 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 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 different from typical DDD layers. The main reason is to make it more suitable for 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 classic 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. The rest is where the EFA and typical DDD approach differ. In EFA, the 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.
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 2 src/ui src/ui ├── Button │ ├── Button.spec.ts │ ├── Button.stories.ts │ └── Button.tsx ├── Input │ ├── Input.spec.ts │ ├── Input.stories.ts │ └── Input.tsx └── Table ├── Table.spec.tsx ├── Table.stories.tsx └── Table.tsx
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.
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. Container components 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.
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 have incorrectly identified 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.
UI Layer components have to be domain agnostic, so you might extract only a base component to the UI Layer and extend it in the appropriate domain.
#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: Your 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.
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 only pure business logic, agnostic from other layers and frameworks. This is because the place for business logic in the application is mostly beyond the client application. Otherwise, it's just leaky abstraction in your API.
Since I argue there's no true business logic on the client, 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, in 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. Ultimately, I 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 the client, 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 logic. This doesn't mean there's no business complexity, and the one that is doesn't fall under typical Layered Architecture. It still can have layers, just different ones.
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 (i.e. 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. With a page component in the Application Layer, you can compose container components into one page without crossing any domain boundaries.
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. Those APIs might not be the best suited to use directly. In another article, I've described why using LocalStorage directly is a bad idea. Such a wrapper that extends native API with an additional safety net belongs in the Infrastructure Layer.
Another great candidate for the Infrastructure Layer module is an implementation of the Facade pattern for complex APIs like Web Bluetooth API. Even simple APIs can be difficult to test reliably. Think about
Date (time zones) or
process.env (global, data races). 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 ├── persistence │ ├── graphql.ts │ └── storage.ts ├── services │ ├── experiments.ts │ ├── monitor.ts │ └── tracking.ts ├── sw.ts └── theme.ts
You may be asking yourself, how Infrastructure Layer is different from typical
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. It's not that much different from how we use it in EFA in the grand scheme of things.
Subdomains are where the majority of the project code is. At the highest level, each subdomain defines its exports. Typically, for the component-based framework, those exports are going to be container components. The code on which those container components depend is placed in the appropriate directories down the tree. This structure clearly shows what is a public API of the domain (container components) and what is private and subject to change (api/components/hooks/types).
Nothing outside the subdomain should import this code. Container components should depend only on their subdomain and no other. Each subdomain provides high-level, reusable, and self-containing building blocks for the Application Layer. It's essential not to make too many assumptions about how Application Layer is consuming container components.
$ tree -L 2 src/domain/orders src/domain/orders ├── OrderForm.spec.ts ├── OrderForm.tsx├── OrderList.spec.ts ├── OrderList.tsx├── api │ ├── OrdersRepository.spec.ts │ └── OrdersRepository.ts ├── components │ ├── ShippingAddressAutocomplete.spec.tsx │ └── ShippingAddressAutocomplete.tsx ├── hooks │ ├── useOrders.spec.ts │ └── useOrders.ts └── types ├── CreateOrder.ts └── Order.ts
This is an example of how we can structure a simple subdomain. The internal structure is arbitrary, and in this example, only demonstrative. Small subdomains have a flat structure, but larger ones with multiple components might benefit from the additional level on which you can read more in the next section.
It might be tempting to export entire views from the domain layer. Unfortunately, in practice, this approach often fails miserably. Initially, it makes sense that a product page is part of the product domain. The biggest issue with this line of thought is that the product page, in practice, is i.e. 70% product, 20% catalog, 20% order, and 10% user. This is where you find yourself crossing domain boundaries. That's why it's better to let the Application Layer be responsible for putting pages together.
$ tree -L 2 src/ui/DatePicker src/ui/DatePicker ├── DatePicker.tsx├── components │ ├── DatePickerInput.tsx │ └── DatePickerRangeInput.tsx ├── hooks │ ├── useDate.ts │ └── useDateRange.ts └── utils ├── calendar.ts └── timezone.ts
By looking at this
DatePicker component at how it's structured you should be able to tell which files are public and what is private. Public
DatePicker.tsx is the only module that is meant to be imported by an outside module. Directories
utils contain private modules that shouldn't be imported by an outside module.
Follow package scope structure when the complexity of the given component/service justifies separating it into several files. This encourages the best type of cohesion, a functional cohesion, which groups parts of the module that perform one task. Here, providing the
DatePicker component. By disallowing (even conceptually) other modules from "deep" imports from a different module we avoid coupling.
Does it look already familiar? From the perspective of the Application Layer, our subdomains also follow the idea of package scope. The subdomain only exports container components at the top level.
Another way to achieve a similar result (other than by directory tree) is to have an
index.js file that reexports the public modules. It's not my preferred way as it requires additional manual work of writing those reexports and then looking into the index file to learn what's public. Depending on your transpiler setup and type of modules (CommonJS), you might have a hard time tree-shaking such index files.
Check out this fragment of the talk to learn more about benefits of package scope (in Java).
Thank you for getting this far, and remember, Elegant Frontend Architecture is not a framework, but a bunch of malleable ideas, so make this architecture your own. With many rules to follow, the most important is to be pragmatic. Break the rules when needed.
Photo by Yancy Min on Unsplash.