DEV Community

Cover image for Domain driven architecture in the frontend (I)
Cesar Martinez
Cesar Martinez

Posted on • Edited on

Domain driven architecture in the frontend (I)

This is the Part I of the article. If you're looking for Part II about Primary and Secondary the link is right here

This article is about what domain driven architecture is, why it could help you, and how I've learned to implement it in the frontend.

What

This one goes out to any frontend developer that has ever found himself dreading the thought of working on a codebase because it's just too hard to follow what anything is doing anymore.
I'm writing this because I've been there myself: you start working on a project and in the beginning everything is a breeze, but as it grows it becomes harder and harder to get anything done on it. Over time, something that was exciting becomes a hassle, and the motivation to work suffers because of it. This can happen even if you follow best practices and the recommended style guide to the letter. If you can relate, then domain driven architecture can help you.

The domain driven architecture I propose here is one implementation of some of the principles of domain driven design (DDD), the development philosophy defined by Eric Evans in "Domain-Driven Design: Tackling Complexity in the Heart of Software" (2003). 

Why

One of the main reasons this architecture is put in place is to help developers manage the complexity which increases over time in the codebase. Another reason is to enable developers to focus most of their attention and intelligence in thinking about the problem the business is solving (its domain).
In other words, we tell devs:

"Focus on figuring out the best solution possible. When you need to implement it just follow the architecture".

Architecture decisions are usually limits we impose on our liberty as devs, but which in return increase the organization, predictability, readability, and testability of the project. Domain driven architecture increases the initial cost of building a project, but greatly reduces the cost of maintaining it. Domain driven architecture is thus justified for any solution projected to exist (and evolve) in the long-term.

One of the main reasons software becomes complex and difficult to manage is due to the mixing of domain complexities with technical complexities. Evans describes this problem as "code that does something useful, but without explaining how".
(Patterns, Principles, and Practices of Domain-Driven Design. Scott Millett, Nick Tune, 2015)

How

Before going over the particular architecture decisions I have learned to follow and how they've helped me and my team, let's go over how a typical frontend application looks like. 

The "problem"

Check this representation of a typical frontend architecture design taken straight from Huy Ta Quoc (link to the resource below). Does it looks familiar?
Typical frontend architecture design

(Check out Huy Ta Quoc's very well-written article on the topic: https://dev.to/huytaquoc/a-different-approach-to-frontend-architecture-38d4)

Imagine we're building an application to store and share cooking recipes. Under this design the code may resemble something like this:

<template>
  <h1>Add Recipe</h1>
  <form>
    <div class="form-group">
      <label for="name">Name</label>
      <input type="text" v-model="name" />
    </div>
    <div class="form-group">
      <label for="ingredients">Ingredients</label>
      <textarea v-model="ingredients"></textarea>
    </div>
    <div class="form-group">
      <label for="instructions">Instructions</label>
      <textarea v-model="instructions"></textarea>
    </div>
    <button @click="save">Save</button>
  </form>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useRecipeStore } from '@/stores/recipeStore'

const name = ref()
const ingredients = ref()
const instructions = ref()

const recipeStore = useRecipeStore()

async function save() {
  recipeStore.saveRecipe(name.value, ingredients.value, instructions.value)
}
</script>
Enter fullscreen mode Exit fullscreen mode

(Even though I'm showing a Vue app, these teachings apply to any modern frontend framework)

I'm sure you've seen it though: the saveRecipe action would then make an API call with those parameters to save it in the backend, and probably also save the new recipe in the store.
Here's what the code displaying all the recipes may look like:

<template>
  <h1>Recipes</h1>
  <RecipeCard v-for="recipe in store.recipes" :key="recipe.id" />
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import RecipeCard from '@/components/recipe'
import { useRecipeStore } from '@/stores/recipeStore'

const store = useRecipeStore()

onMounted(async () => {
  // If we have the recipes in store, don't refetch
  if (store.recipes.length) return
  await store.fetchRecipes()
})
</script>
Enter fullscreen mode Exit fullscreen mode

For a time, this is probably fine for many projects. But after a while, as complexity grows, the flaws of this design will begin to surface.

State management is tightly coupled to the components.
It took me a while to understand why this is a problem. After all, that's exactly how the official documentation showcases the usage of the library. How can this design pattern be wrong if the people writing the library display it like that? Well, it may or may not be wrong for you. The trick is in spotting the difference.

As said in the beginning, it all depends on the project. What's important is to make a conscious decision about the architecture the devs are choosing for the project, instead of following blindly what's shown in the docs and tutorials.

If, for whatever reason the team decides to change the state management library, this tight coupling will make such a task pretty difficult. "But we would never do that!" you may be thinking. You may be right... but you do want to update to newer versions of the same library, don't you? Well, Kia King said it himself, "... consider Pinia as Vuex 5 with a different name". For me, when the time came to make that upgrade, I was happy state management was loosely coupled.

Loose coupling with your state management can also greatly simplify components. If there’s something we can all agree on as frontends is that the dumber the components, the better. By dumb components, we mean simple components. Those that don’t have to do lots of things; they focus on one thing and do it correctly. Ideally, a component should just receive some data and display it, and that’s it, right?

I couldn't agree more! Looking at our code, however, what's happening in the onMounted hook of the "Recipes" component? In that piece of code the component is basically deciding its caching strategy... that shouldn't be there. We want our components to call the data it needs, and it shouldn't care at all whether the data is coming from cache, or from the API, or from LocalStorage, or whatever.
Indeed, to fix this you can have that check in the action itself and remove it from the component... but there may be a better, more comprehensive solution.

Something I used to like but now I find it to be an issue, is that it allows you to call an action to fetch data in the component and then "magically" have that data available via the getters. Now I'd much prefer a more declarative way of coding, for example, calling fetch and assigning its results to a data property of the component directly. No magic involved, just easy-to-follow code. This makes it so that you don't need to know Vue to understand what's happening.

Let's take a look at another example of code you've probably seen before:

<template>
  <div class="recipeCard">
    <div class="h2">{{ name }}</div>
    <div
      :class="{
        danger: difficulty === 'hard',
        warn: difficulty === 'intermediate',
        success: difficulty === 'easy',
      }"
    >
      <span>{{ difficulty }}</span>
      <RecipeTutorial
        v-if="difficulty === 'hard'"
        class="recipeTutorial"
        :steps="steps"
      >
        {{ tutorial }}
      </RecipeTutorial>
    </div>
    ...
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

The idea here is that the component behaves differently depending on the state of the data it receives (the recipe difficulty level in this case). You probably see similar code every day.

The issue here is that the knowledge regarding how the recipe state affects the application is hidden away in the component. This code does something useful but it's not explaining how. On one hand it's adding complexity to the component, which ideally should remain as dumb and simple as possible. On the other hand, that knowledge hidden away in the component is not reusable.
This would be ok if the behaviour is particular to that component only (which is not necessarily the case here because one can imagine that every time a recipe's difficulty is referenced it needs to use that particular class or icon). So you may decide to create an even smaller component that accepts the recipe difficulty and displays it as it should in every case. This is definitely a plausible solution, but still new devs will need to dig into the component tree to find the relevant application knowledge. There may be a better solution...

Before we talk about the solution, let's go over one final piece of code you've probably seen countless times:

export const useRecipeStore = defineStore({
  id: "recipes",
  state: () => ({
    recipes: [] as Recipe[],
  }),
  actions: {
    async fetchRecipes() {
      const { data: recipes } = await axios.get<ApiRecipe[]>("/recipes");
      this.recipes = fromApiToRecipes(recipes);
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Here the state management library (Pinia in this case, but it can be Vuex, Redux, XState or any other) is used to interact with the api to fetch the data, adapting that data to whatever the application needs (with fromApiToRecipes), and also saving the result in the store.

The issue here is that the state management library is taking in more responsibility than it has to. The state management library should ideally only be used for state management. This effectively means that all the code in your stores should only pertain to the issue of state management. It shouldn't be the store's responsibility to run unrelated side-effects, format the data for components, make API calls... All of which you can find throughout store actions in many codebases. This leads to stores growing to immense complexity even for relatively simple apps (I have certainly been guilty of this in the past).

I have also seen actions being used as containers for api calls, bypassing the store altogether!

actions: {
    async fetchRecipes() {
      const { data: recipes } = await axios.get("/recipes");
      return recipes;
    },
  },
Enter fullscreen mode Exit fullscreen mode

And I have also seen multiple components calling actions on created or mounted without anyone ever checking to see if this data is already available in the store. If you open the network tab in these apps and use it, you'll find the same api calls with the same parameters being made over and over again... Oh, the wastefulness!

All of these problems have, in my experience, a single root cause: lack of defined responsibilities in the code. Just like with people, when some code has too many responsibilities at the same time, it's bound to handle them poorly.

The A solution (the Hexagon)

Let's start then, by separating and defining some responsibilities.

Image description

The first thing you see (and probably the most important) is the decision to separate (and protect) the domain from the rest of the application (primary), and from its infrastructure (secondary). Let's see what's the deal with these new directories.

(The reason I personally prefer the naming primary instead of application is that the latter can be confused with the "application domain" or "application service" that some apps contain. And I prefer secondary instead of infrastructure (or infra) because the word may refer to a directory reserved for DevOps where they place configs for Docker, Terraform, Ansible.)

Domain

The first and most important new directory in this proposed architecture is domain. The domain is the core of the application; the engine that solves the business problem. You should be able to swap the whole domain out from the application and place it in a different context (in Svelte, ReactNative, electron, anything), or connect it to any external provider (GraphQL, Firebase, REST), and nothing in the domain would need to change for it to do its job. In other words, our goal here is to write domain code that would be easily interchangeable with a different application (primary) or a different infra (secondary).

Note that, even if you're certain that you won't ever need to make such a change in your application, just having this goal opens the path to a more maintainable, more testable, better organized codebase.

The domain directory will contain directories that reference the main data models used throughout the application. And inside those there will be a file with the same name as the directory containing a Class, also with the same name (with the uppercase). The domain directory will also be shared by whatever exceptions, types, enums or constants are referenced in the domain model.

Image description

Domain and business go hand in hand. Ideally, business experts should be able to read domain code and understand what it's doing. Indeed, the origin of the language used in domain code should be their business expertise. This because the wording should be common among everyone working in the project, not just the devs. The domain language should be shared between the experts, everyone working in the solution (usually the product team), and the code. In other words, domain language should be ubiquitous.

Note that there's no single person who is "the domain or business expert". It's just a catch-all concept for anyone that provides domain knowledge to the team implementing the solution. Could be a Product Owner, the CEO, a business dev, a customer service agent, an advisor, another dev...

Sometimes the hardest part of all this is deciding what is part of the domain and what isn't. I will be giving my tips on how to tackle this later. I can already say, however, that in my personal experience, whenever I get to implement a new feature or augment an existing one under this architecture, the fact that I have to think about this puts me in a better position than I would've been otherwise as a dev. It forces me to listen, to ask more questions, and to care about understanding the Domain, because that understanding directly informs the code that goes in the domain directory.

The better your understanding of the business (domain), the better the code you'll be able write.

This is probably the biggest advantage of domain driven architecture in the front: it forces devs to think about the domain for themselves, instead of blindly inheriting it from the backend. Truth is, in many cases, backend and frontend teams may have different considerations in mind when they make their decisions. As we'll see, not all the properties that exist in a Domain object as defined by the backend are necessarily required in the Domain object as implemented by the front. Considering each data property and thinking if/how it's going to be used by the front is part our responsibility as frontend devs.

Domain rules

All change to domain objects is encapsulated and happens in a controlled manner. There is only one way to create domain objects, only one way to access them, only one way that it can mutate. The goal of the architecture decisions with regards to domain objects is to protect it from manipulation anywhere outside this controlled manner. That's how the increasing complexity is managed. Let's see some code and discuss a few of these architecture decisions.

import { Unit } from "@/domain/ingredient/enums";
import type { IngredientProperties } from "@/domain/ingredient/types";
import { InvalidUnitForConversionException } from "@/domain/ingredient/exceptions/InvalidUnitForConversionException";
import { OUNCE_IN_GRAMS } from "@/domain/ingredient/constants";

export class Ingredient {
  private constructor(
    private readonly id: string,
    private readonly name: string,
    private quantity: number,
    private unit: Unit,
    private readonly updatedAt: Date
  ) {}

  static fromProperties(properties: IngredientProperties) {
    const { id, name, quantity, unit, updatedAt } = properties;
    return new Ingredient(id, name, quantity, unit, updatedAt);
  }

  get properties(): IngredientProperties {
    return {
      id: this.id,
      name: this.name,
      quantity: this.quantity,
      unit: this.unit,
      updatedAt: this.updatedAt,
    };
  }

  changeGramsToOunces() {
    if (this.unit !== Unit.g) {
      throw new InvalidUnitForConversionException(
        this.unit,
        Unit.oz,
        this.changeGramsToOunces.name
      );
    }

    const quantityInOunces = this.quantity / OUNCE_IN_GRAMS;
    this.unit = Unit.oz;
    this.quantity = quantityInOunces;
    return this;
  }
}
Enter fullscreen mode Exit fullscreen mode

I tried to think of an example domain class with a useful method that uses a bit of all the ingredients I showed in the screenshot above (exceptions, types, enums, constants). Here is the codebase source. There's quite a few things to unpack here.

  1. Domain properties are domain Types which represent the current state of the data in a domain object.

  2. The constructor is private. We use domain properties to instantiate domain objects via a static method: static fromProperties(properties: IngredientProperties): Ingredient.

  3. All properties in a domain class begin as private readonly. Only when it becomes apparent that a property needs to be able to change, then only that property is allowed to be editable.

  4. The domain classes can throw exceptions. These exceptions tell developers "if you are seeing this, you screwed up somewhere else". They may also teach the developer something about the domain (like you can't convert mass to volume). Normally, if the method changeGramsToOunces is to be used in a form, for example, then the UI code is expected to include its own validation before passing it to the domain.

  5. A domain method is the only place where the properties of a domain object can ever be mutated.

  6. The way to access data from a domain instance is via the getter get properties(): IngredientProperties. However, as we'll soon see, your components will never access the properties of a domain object.

So how then, do components get access to the information in domain objects? This is where primary comes in. Primary is the tunnel that connects your components (UI), to the domain, and to your API and other external sources (secondary). We'll talk about all of that in the next article, this one is already too long as it is.

Repository

Before we go though, there's one last simple (yet important) piece of the domain puzzle I'd like to share with you now: the Repository.

All the data that your application handles is represented by domain repositories. Here's an example:

import type { Recipe } from "@/domain/recipe/Recipe";
import type { RecipeId, RecipeToSave } from "@/domain/recipe/types";
import type { UserId } from "@/domain/user/types";

export interface RecipeRepository {
  getRecipes(userId: UserId): Promise<Recipe[]>;

  getFavoriteRecipes(userId: UserId): Promise<Recipe[]>;

  createRecipe(userId: UserId, form: RecipeToSave): Promise<Recipe>;

  updateRecipe(recipeId: RecipeId, form: RecipeToSave): Promise<Recipe>;

  deleteRecipe(recipeId: RecipeId): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

The repository is simply an interface in which you define the contract that will be implemented by your infrastructure (secondary).

This contract is part of what allows you to copy your whole domain directory and paste in any other app or infra (from a Vue web app to an Electron desktop app, for example). Everything should continue to work as expected as long as the contract is implemented (almost, we'll discuss a caveat later).

The repository also provides developers new to the codebase an easy-to-parse overview of all the ways the domain can interact with external sources.

These are the rules of the repository:

  1. It's an Interface of methods. It's not a Type because it will be implemented by a class (which we'll discuss in the second article).
  2. Its methods may receive primitives, domain properties, and domain types as arguments.
  3. Most importantly, they always return domain objects (or void);

This is as far as I'll go about the domain now. Next, on to the second article to discuss how the rest of your application can take advantage of this domain we've created.

Top comments (1)

Collapse
 
aesta profile image
Anastasia Kuchurina

Great stuff! I am so glad that I stumbled on the Clean Architecture topic. We all should talk more about DDD and its variations in the frontend. Apps are so tightly coupled with frameworks. It is painful to think of changing.