Scale up your react application with DDD !

This article is meant as a guide, or at least an inspiration, to facing some issues when scaling up a react application.

We are not going to talk here about performances. Neither will we talk about micro front-ends and any kind of tooling.
We will center our attention on the code itself, its structure, and its organisation. Answering questions such as:

  • How to keep a clean structure as the amount of code grows bigger ?
  • How to ease the integration between your teams as the number of contributors grows ?
  • How to manage feature interdependencies ?

In this guide, we will be looking at the broad foundations of an application; What an arguably good structure could look like. Then, we will zoom in on the details, ie. what composes this structure and how.

Build your foundations with DDD

Early days…

Let’s start off by discussing the folder structure. The React documentation says very little on that topic. It mentions not to think too much about it if you are just starting a project, which is sound advice.

However, as our app grows, we need to put some thorough thought into it. An ill-defined structure can feel awkward and unstable. We can end up with pieces of code that are separate when they should live together, and vice-versa. Which might push developers to make wrong design decisions, like duplicating code they should not, build the wrong abstractions…

This structure below is fine to start with, especially when we don’t have a good grasp yet of the product

pages/
  ├─ pageA/
    ├─ components/
api/
types/

While it works fine for small / early-days projects, it is not suitable for bigger ones. A few of the hassle we would face:

  • gigantic folders
  • unrelated components being mixed together as nothing prevents a page from displaying very different things
  • an unstable structure. We have to move our components when they are used in several pages, along with apis…
  • a types folder that will very soon become a big mess. As a consequence, interfacing components with each other, or APIs, will become harder.

A little digression on the last point because it’s usually overlooked.

If not careful, we would find ourselves in the situation where we reimplement part of an existing feature (components, business logic) because we can’t interface properly with the existing code (types do not match / the code is tied to some extra feature we don’t want / etc.) 
As a consequence we would have to maintain multiple branches of things that are similar, for example multiple components that do almost the same thing. This adds to the burden of maintenance, along with cognitive load to the development team.

…to feature driven structure

To mitigate the drawbacks we listed, a new app structure, split into features, emerged.

It basically looks like this

features/
   ├─ featureA/
      ├─ components/
      ├─ hooks/
      ├─ types/
      ├─ api/
   ├─ featureB/
pages/
   ├─ page1.ts

It is in no doubt a big improvement. However, how do we define what a feature is ? Where do we put our domain rules ? How to make sure that the concepts we put in those features are correctly named, correctly typed, and consistent across components, apis, etc. ?

A pinch of Domain Driven Design

To iterate further on the feature structure, we will explore how to organize our react application using Domain Driven Design (DDD).

Here, we want to structure our application by modules and contexts. A context is a bounded context that matches a subdomain of the business we are addressing. A module encapsulates concepts defined in that subdomain.

It might be fuzzy at first but we will go through it.

By domain, we refer to the entire domain of the business the application relates to. A subdomain is a coherent part of this domain. If we take the example of an e-store (drawn from Implementing Domain-Driven Design), catalog / orders / invoicing / inventory would all be subdomains (the list is not exhaustive). 

Another way to think of it is a vertical slice of the domain.

Ideally, this subdomain should match a bounded context. A bounded context is a linguistic boundary. Meaning that every domain model term inside that bounded context should have a unique, unambiguous meaning. The same term could be present in another bounded context, but mean something different, and thus be modeled differently. For that reason, it is best if a single team is responsible for a bounded context.

The bounded context name will be the root folder of the related modules.
A module inside the bounded context represents a domain concept.
Practically speaking, a module is merely a folder centralizing everything pertaining to the domain concept in question (components, interface definitions, apis, redux slices, etc.).

To give the example of a Jira application (drawn again from Implementing Domain-Driven Design of Vaughn Vernon), we could have 2 bounded contexts

  • agile project management context,
  • identity and access context

In the agile project management context, we have modules representing concepts such as sprint, backlog, story points, contributor, product manager…
In identity and access context, we have concepts such as user, permission, roles…

As you can see, the terms employed reflect concepts closely related to the context they live in. A user for example would not be suitable as a concept in the agile project management context, because it does not accurately model the concepts of collaborator or product manager.

Modular structure for vertical slices

Let’s see in practice what the structure would look like.
We will dive into the different folders making up a module later on

bounded_context_A/
   ├─ module_A/
      ├─ __fixtures__/
      ├─ domain/
      ├─ components/
      ├─ hooks/

   ├─ module_B/
      ├─ submodule_A/
      ...

💡If necessary, we can add a common or utils folder at the root that contains anything not related to the domain, ie. anything that does not fit into a module and context. These could be general helpers, lodash-like, translations handling, etc. Generally speaking, cross cutting utils.

Module discovery

Now that we have acknowledged that we first needed to identify our bounded contexts and subdomains, let’s try to discover our modules.

Defining them properly is important. If ill-defined, we would find out later that concepts that should live together are actually scattered. That would make defining appropriate interfaces, and grouping relevant components and domain logic harder.

Wrong path of drawing the structure from the design

To illustrate this, let’s take a look at a naive and flawed way to do it.
⚠️ Do not copy this method !

We will take the example of a sprint planning application like Jira (same example we have in Implementing Domain-Driven Design).

Let’s assume we have 2 pages: 

  • a backlog page: /backlog from which we can assign an item to a sprint
  • a sprint page: /sprints/:id

Since we have a backlog page, it’s seems natural to create a backlog module. Same for sprint. In the backlog page, we have essentially 2 views:

  • the list of backlog items items
  • the details of a single backlog item when we click on it.

Those 2 views (listdetail) will be the submodules of the backlog module, since they kind of look like features of the backlog.

By the same process, on the sprint page, we can view the dashboard of planned tasks & the details of a specific task.

That gives us a base structure like so:

backlog/
   ├─ list/
   ├─ detail/
sprint/
   ├─ view/
   ├─ detail/

Let’s flesh it out a bit, imagining what it would look like after some development time:

backlog/
  ├─ list/
    ├─ components/
      ├─ BacklogList.tsx
      ├─ BacklogRow.tsx
      ├─ AssignToSprintDropdown.tsx
    ├─ api/
      ├─ getBacklogList.ts
      ├─ assignItemsToSprint.ts
    ├─ types/
      ├─ backlogList.ts
      ├─ sprint.ts
  ├─ detail/
    ├─ components/
      ├─ BacklogItem.tsx
      ├─ AssignToSprintAutocomplete.tsx
    ├─ api/
      ├─ modifyItem.ts
      ├─ assignItemToSprint.ts
    ├─ types/
      ├─ backlogItem.ts
      ├─ sprint.ts
sprint/
  ├─ view/
    ├─ components/
      ├─ Dashboard.tsx
      ├─ DashboardItem.tsx
    ├─ api/
      ├─ getSprintTasks.ts
    ├─ types/
      ├─ sprint.ts
  ├─ detail/
    ├─ components/
      ├─ SprintItemDetail.tsx
    ├─ api/
      ├─ getSprintItem.ts
    ├─ types/
      ├─ sprintItemDetail.ts  

That’s a non-exhaustive overview of what we could end up with, by drawing the app structure out of the pages designs and functionalities.

There are many things wrong with this approach.

Ill-defined domain

Our 2 modules are somewhat redundant. We have 2 sprint definitions, one in each module, likely with different interfaces each; especially if they were created by 2 different teams. That means the 2 sprint definitions between the 2 modules are incompatible which is quite troublesome:

  • in both modules, they represent the same concept. Hence they should be modeled in the same way
  • if a new developer stumbles upon a sprint variable, it will sometimes have one specific interface, sometimes another. This is confusing.
  • one module cannot reuse a component from another one, because the interfaces are different.

One might argue that the team in the backlog module had different needs for the UI than the team in the sprint module. Hence they have definitions that match their specific needs which is fine. Also it is kept optimized this way because it’s minimal for what they need.

Let’s debunk that proposition.
Essentially, what is wrong is making the domain dependent on the view. Here, the definition of what is a sprint will depend on what is displayed on the page. It should not ! The concept of sprint is the same in both modules. Hence, conceptually, the definition should be the same.

Moreover, that approach has the following undesirable consequences:

  • When the design changes, we may need to change the domain definition
  • We will have a hard time sharing components related to sprint between different modules (sprint / backlog) because the interfacing won’t work. It’s like fitting squares into circles.
    One might want to solve this issue by moving the shared components in a common directory next to the modules. But not only would it create a brittle structure, where its organization depends on the views, it would also blur the ownership: who maintains this common folder ? Besides, what definition of sprint does it rely on ? The one from backlog, sprint, or an ad-hoc third definition ?
  • It’s harder for a new developer to understand what a sprint is, since it has multiple definitions for the same name
  • If you have multiple teams, each of them responsible for a module, then the sprint ownership is spread among multiple teams

Same reasoning goes for any kind of domain concepts like the BacklogItem.
In the diagram below we  illustrate the interface compatibility issue. The nodes in red are shown with different shapes, indicating a specific (and incompatible) interface, yet they operate in the same concept, a backlog item.

---
title: Backlog Item interface divergence
---

flowchart TB
    subgraph backlog
      subgraph list
        BacklogList
        BacklogRow([BacklogRow])
      end

      subgraph detail
        BacklogItem>BacklogItem]
      end
    end

  subgraph sprint
      subgraph view
        Dashboard
        DashboardItem
      end

      subgraph detail-sprint [detail]
        SprintItem[/SprintItem/]
      end
    end
   


classDef subgraph_styles fill:#AAAAAA,stroke:#333,stroke-width:4px;
classDef overlap_task fill:#FFAAAA

class backlog,sprint subgraph_styles;

class BacklogItem,DashboardItem,BacklogRow,SprintItem overlap_task;

Taking a domain driven perspective

Let’s start by listing the concepts that compose our agile project management bounded context with rough definitions:

  • Sprint – time boxed iteration during which backlog items are worked on
  • Backlog – a list of potential work items
  • Backlog Item – a single piece of work
  • Story – a feature from the user perspective
  • Task – a unit of work broken down from a user story
  • Bug – a defect to fix
  • Epic – large user stories

This list provides us with the vocabulary expressing concepts that we want to model, in the context of agile project management. This vocabulary is unequivocal and shared by the team members, whether technical or not.

Our app also needs to determine simple permissions on the user level, such as the permission to delete an item. This is not something related to agile project management. Here, we are in the identity and access context. We won’t deep dive in this context, but let’s mention it nonetheless.

Basing our app structure on our discoveries, this would give us the following: 

agile-project-management-context
  ├─ backlog/
  ├─ backlog-item/
    ├─ task/
    ├─ story/
    ├─ bug/
    ├─ epic/ 
  ├─ sprint/
  
identify-and-access-context
  ├─ user/
  ├─ permission/

Since a task, epic, bug and story is a backlog item, we place them as submodules.

Besides, they will implement a common BacklogItem interface. This will allow us to share components, apis and pieces of logic.

You might think that the structure is actually fairly similar to what we had before. However, the key point here is that the structure is determined by observing the domain, and composed of modules / submodules whose names are part of the ubiquitous language that we identified earlier. It is not built out of chance. And this will allow us to avoid the pitfalls we faced earlier.

Now for a more exhaustive look:

agile-project-management-context
  ├─ backlog/
    ├─ domain/
      ├─ backlog.ts
    ├─ components/
      ├─ Backlog.tsx
    ├─ api/
      ├─ queryBacklog.ts

  ├─ backlog-item/
    ├─ task/
      ├─ domain/
        ├─ Task.ts
      ├─ components/
        ├─ TaskCard.tsx
    ├─ story/
      ├─ domain/
        ├─ Story.ts
      ├─ components/
        ├─ StoryCard.tsx
    ├─ bug/
      ├─ domain/
        ├─ bug.ts
      ├─ components/
        ├─ BugCard.tsx
    ├─ epic/ 
      ├─ domain/
        ├─ epic.ts
      ├─ components/
        ├─ EpicCard.tsx
    ├─ domain/
      ├─ backlogItem.ts
    ├─ api/
      ├─ queryBacklogItem.ts

  ├─ sprint/
    ├─ domain/
      ├─ sprint.ts
    ├─ components/
      ├─ Dashboard.tsx
      ├─ SprintDowndown.tsx
      ├─ AssignItemToSprintDowndown.tsx
    ├─ api/
      ├─ querySprint.ts
      ├─ assignItemToSprint.ts  

Let’s take notice of the following points:

  • The type folders were replaced by domain folders. It emphasizes the fact that it contains definitions of domain concepts only.
  • The components, apis, etc. are now gathered in their relevant domain module, regardless of where they are called. For example, the AssignItemToSprintDowndown component was moved from the backlog module to the sprint module.

Here are the relationships between each module

graph LR
    A[backlog] -->|uses| B(backlog-item) & C
    C[sprint] -->|uses| B

Meaning that backlog and sprint will import components and interfaces from backlog-item, and backlog from sprint.

Now that our backlog-item components are centralized, it can easily be owned by a dedicated team that exposes them and handles the related apis.
Other teams can simply reuse components from downstream modules.

---
title: Example of module dependency
---

flowchart TB
    subgraph backlog
        List
     end
   
      subgraph backlog-item
       BacklogItemCard
        subgraph task
          TaskCard
        end
        subgraph story
          StoryCard
        end
        subgraph bug
          BugCard
        end
        subgraph epic
          EpicCard
        end
      end

  subgraph sprint
        Dashboard
        SprintDowndown
    end
   
   Dashboard -. uses .-> BacklogItemCard
   List -. uses .-> BacklogItemCard

classDef subgraph_styles fill:#AAAAAA,stroke:#333,stroke-width:4px;
classDef overlap_task fill:#FFAAAA

class backlog subgraph_styles;
class sprint subgraph_styles;
class backlog-item subgraph_styles;

class BacklogItemCard overlap_task;

Example

As a snippet example: From the sprint Backlog component (backlog module), we want to display the details of specific backlog item, encapsulated in the component BacklogItemCard (backlog-item module)

Deep look inside a module

We specified that a module, or submodule usually have the following folders: 

  • components
  • apis
  • domain

Let’s detail them a bit.

components

components/ is the UI layer that pertains only to the module domain scope.

For example, If the module is sprint/, a potential component would be Dashboard. However, a component such as SprintTask is probably mis-designed. The concept of SprintTask is not a real domain concept. The real concept is a Task (which lives in the backlog-item module) that is assigned to a sprint.

Our components & hooks should be as lean as possible.

  • Components should only contain the UI layer related to the domain, not the design system. (cf design system section for the distinction). In a nutshell, the components should be built on top of domain-free presentational components (Button, Modal, Table, etc.)
  • They should do one thing only, and generally speaking follow SOLID principles
  • Finally, they should not contain domain concept definitions, and business logic implementations. Those should live in the domain/ folder. However, they do rely and import interfaces and functions from the domain folder. That way your domain logic is not coupled to the presentation layer.

apis

The api folder contains everything related to querying the back-end data pertaining to the module domain scope.

Still DDD

When we create our APIs, we should use the same DDD discovery process as mentioned earlier. We strive to make the API endpoints be part of the ubiquitous language.

It’s also important that the entities interfaces returned remain the same across the different APIs of the bounded context.
For example if different apis return a Task, its interface should remain consistent.

We can achieve that by defining that interface independently of any UI interface, ie. decoupling the api from the UI.

Benefits:

  • The APIs can be consumed by multiple clients (mobile app, browser app, public API, CLI …) whose needs are different, so it’s important that it’s not “custom”.
  • Returning a standardized interface eases interfacing for the consumers. Think of a front-end which needs to display the same component (for example a BacklogTask), in different contexts (backlog / sprint…). If the interface of a BacklogTask remains the same, the same component can be reused, along with all the related logic.

Anti-corruption layer

If the data returned by an API is not satisfactory, because the interface is not exactly what we’d expect (especially when dealing with legacy), then we may add a transform layer (anti-corruption layer) at the api level.
This layer is merely functions that transform data coming from the API into the interface defined in our domain/ folder. It prevents polluting our module. This layer should be encapsulated inside our api layer and be transparent for consumers of our apis.

domain

This folder, often overlooked yet crucial, contains our domain concept definitions (the interfaces), as well as the business logic associated. Essentially, it captures the model of the business the app works on.
We strive to represent the business rules as pure functions (free of side effects). Not only will it be way easier to test, it will also allow us to easily apply our business logic across different contexts (from a component, hook, redux, etc.).
This folder is framework free. Frameworks evolve, are replaced, but the domain logic should not be impacted by those changes. We can think of it as being the core of an onion architecture. Whether we rely on redux, hooks, or whatever library, the dependency is unidirectional.

Example
As an example, let’s say we have to code the following business rule in the front-end: a story is done when all related items are done.
Like we mentioned above, our business rule must be modeled in the domain folder of the Story submodule.

Notice how the interface of computeIsStoryDone is Story, not BacklogItem[]. Having this will ease interfacing, especially if later we need to change the business rule involving some other properties, or call another function which relies also on Story (instead of some specific sub-interface).
Furthermore, it encapsulates what data is needed to perform the computation. The client just passes a Story, without the need to know what’s used inside

But most importantly, our components, apis, and domain logic should follow the same interface; and that’s the one defined in the domain.
The interfaces returned from the api layer should match the interfaces defined on the domain folder. The idea being that all the things that compose our modules speak the same language, making interfacing one with another straightforward.

---
title: Component & Apis sharing domain interface
---

flowchart TB
  
      subgraph sprint
        subgraph components
          dashboard.tsx
        end
        subgraph api
          getSprint.ts
        end
        subgraph domain
          sprint.ts
        end
      end

   
   dashboard.tsx -. prop sprint implements .-> sprint.ts
   getSprint.ts -. returned sprint entity implements .-> sprint.ts
   sprint.ts -. domain logic on interface implementing .-> sprint.ts

classDef subgraph_styles fill:#AAAAAA,stroke:#333,stroke-width:4px;
classDef overlap_task fill:#FFAAAA

class backlog subgraph_styles;
class sprint subgraph_styles;
class backlog-item subgraph_styles;

class BacklogItemCard overlap_task;

Pages out

As we have established, our modules are created on a domain basis, not on the UX design. 
If we consider that pages are aggregated views, there’s no reason why we should tie them to a particular domain scope.

That’s why we keep them outside of our modules/ in a pages/ folder. Each page will import the components it needs to display from the modules.  

Let’s keep in mind that a page can display components belonging to multiple modules, and a component can be displayed on several pages.

---
title: Example of pages / components dependency graph
---

flowchart TB
    subgraph pages
        page1(Page 1)
        page2(Page 2)
        page3(Page 3)
     end
   
      subgraph moduleA[module A]
        subgraph components
          componentA1(component 1)
          componentA2(component 2)
        end
      end
   
    subgraph moduleB[module B]
        subgraph componentsB[components]
          componentB1(component 1)
          componentB2(component 2)
        end
      end

   page1 -. imports .-> componentA1 & componentA2 & componentB2
   page2 -. imports .-> componentA2
   page3 -. imports .-> componentB1

classDef subgraph_styles fill:#AAAAAA,stroke:#333,stroke-width:4px;
classDef page1 fill:#FFAAAA
classDef page2 fill:#AAFFAA
classDef page3 fill:#AAAAFF

class pages,moduleA,moduleB subgraph_styles;

class page1 page1;
class page2 page2;
class page3 page3;

Bread & butter: the design system

Crucial point for scalability: using a design system, ie. a library of “basic” UI components (Button, Modal, Table, List, etc.) following a consistent UX guideline. By basic, I mean that those components are free of any domain logic. They can be used all the same in different apps.

If your team is big enough, and you have competent resources to dedicate to it, then it can be built in-house. Otherwise, you can rely on existing battle-tested solutions such as  react material ui.

Advantages of using a design system:

  • Consistent UX across our app: As our team and application grow, we want our UX to remain consistent from a user perspective. The look & feel should not change depending on which team owns the feature.
  • Higher velocity of feature development: Engineers can focus immediately on the domain layer, not losing time (re)building UI foundation blocks
  • Better code quality: A design library developed by engineers & UX outside of a particular feature tends to be more generic and better designed. Conversely, without a design system, an engineer under deadline pressure might tend to develop custom UI components that’s hard to reuse outside features for which it was required. This could lead to many different versions of the same component being created, but none of them being generic and optimal.
  • Better documentation: for the same reason as above.

In summary, our app components follows a 2 layer-structure:

---
title: 2-layered component structure
---
flowchart TB
  design-system[(design-system)]
  domain-components[(domain-components)]
   
   domain-components -. built on top of .-> design-system

Of course, nothing prevents a domain component from relying on multiple other domain components. But at the end of the chain, you will find design-system components.

Test away

Domain logic

The domain logic can be easily tested using plain unit tests. No mocks should be required (or exceptionally) since it’s be made up mostly of pure functions.

It is highly recommended to have a __fixtures__ folder at the root of the module, containing domain entities factories. Those factories will help us build the domain object instances upon which our module relies. Among the advantages:

  • Prevents tests from being littered with noisy data definitions
  • Simple way to create and override an entity instance with only relevant data to the test
  • Prevents tests from sharing the same data references, making sure tests are not impacting each other
  • Makes it easier to modify the entity interface if needed: we simply need to update the fixture, and the tests relying on the specific property(ies) updated

It can be as simple as that:

Components

Likewise, it is important to test our components. We want to identify quickly and reliably what / where something fails, to have the confidence that no regression is introduced.
On the flip side, testing our component might provide a clue whether it is well designed. A test should not be overly complex and not have too many mocks.
For those reasons we want every component to be unit tested. React-testing library is the current default choice.

Also, we will need to mock api calls:

  • Since we want our mocks to live near the api definitions, we will have them in a __mocks__ folder nested inside the apis folder of our modules. It will contain fixtures of the data returned by the apis.
  • Once we have our mocks, we can use libraries such as nock, msw… to mock them inside our tests. Using the fixtures would allow us to override the properties required for our tests.

Finally, depending on the app, we may need to create some kind of app shell mock, that essentially mocks a real app environment (with a redux store, a global configuration or any other necessary context) the components rely on.

Now it is important that your component tests are as small and focused as possible. We want to specifically test the component behavior, without being side-tracked testing the (already tested) children components behavior (unless we consciously want to test them in a single unit).
That does not mean we would mock the children, but rather that the test is focused on the specific behavior added by the component we are trying to test, simply because the rendered components are already tested.
Same for the components coming from the design system, they should be tested on their own. Hence, testing only the added behavior and the integration between the children components should suffice.

The following graph illustrates this point. The green components (children) are tested, the red not. We can think of it as a bottom-up testing strategy.

---
title: Testing the upmost component
---
flowchart TB
  component
  child-A[child component A]
  child-B[child component B]
  grand-child-A(grand-child component A)
  grand-child-B(grand-child component B)

  component --> child-A & child-B
  child-A --> grand-child-A & grand-child-B

  classDef tested fill:#AAFFAA
  classDef to-test fill:#FFAAAA

  class component to-test;
  class child-A,child-B,grand-child-A,grand-child-B tested;

Side note on components that are strictly visual: relying on automatic visual testing might be a better option than writing an actual test based on regular assertions.

React hooks

For private component hooks, testing them at the same time of the component they pair with is usually enough.
However for exported hooks, meant to be consumed in different places or contexts, testing them independently could prove a better option. React hooks testing library is a convenient tool for that purpose.

Have a documentation

Last but not least. As your team grows, the number of new features grows alongside the interdependencies. So it is wise to have documentation.

A possible approach would be to:

  • document the purpose of the module with a Readme
  • document the hooks and components exported, and that can be consumed by other modules. For example using a Storybook
  • document your apis

Of course, the same goes for the components exported from your design system, if built in-house. A comprehensive documentation is recommended.