DEV Community

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

Posted on • Updated on

Domain driven architecture in the frontend (II)

(This is the continuation of an article about domain driven architecture in the front. To learn what that is, why it can be important and how to implement it in your frontend code, go check out the first part.)

By now you have a domain class, it's types, and a repository. You're probably wondering how that is going to play with your application code and your framework (Vue, React, Angular, Svelte, whatever), and how you're going to use that in your components. I'm sorry to disappoint, but we're still not quite ready to touch framework code just yet, but we're getting closer.

Primary

The reason we can't talk about your framework code just yet is because your domain objects can't go directly into your components. As said in the previous article, your domain must remain protected. Allowing domain objects into the components leaves them open to manipulation of every kind, in all manner of places. That's how complexity becomes unmanageable.

Since we're not providing free access to domain objects, the architecture proposes instead to provide access to View objects.

View objects

Let's dive into the code (sauce here):

import type { Recipe } from "@/domain/recipe/Recipe";
import type { RecipeId } from "@/domain/recipe/types";
import type { Ingredient } from "@/domain/ingredient/Ingredient";

export class RecipeView {
  private constructor(
    public readonly id: RecipeId,
    public readonly name: string,
    public readonly ingredients: Ingredient[],
    public readonly instructions: string,
    public readonly portions: number,
    public readonly updatedAt: string
  ) {}

  static fromDomain(recipe: Recipe) {
    const { id, name, ingredients, instructions, portions, updatedAt } =
      recipe.properties;
    return new RecipeView(
      id,
      name,
      ingredients,
      instructions,
      portions,
      updatedAt.toLocaleDateString()
    );
  }

  get mealSizeIcon() {
    switch (this.portions) {
      case 1:
        return "single";
      case 2:
        return "couple";
      case 3:
        return "family-one-kid";
      default:
        return "family-two-kids";
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, a View is a publicly-accessible representation of a domain object. Views don't have as many rules as domain objects (nothing does actually). You have a lot more freedom to implement into Views what's most convenient for you and your team. Let's see what we can learn from this example:

  1. You are welcome to import domain types and use them to, well, type your typescript code as you please.
  2. The constructor is also private: it needs to be instantiated via the static fromDomain method. The reason we're passing the domain instance and deconstructing the properties instead of just passing the properties, is because domain instances can have getters required by the View that don't exist in the properties.
  3. The properties of the View don't need to match the properties of the domain object. In this example we transform the updatedAt Date into the string that's to be displayed in the UI. Whether that's a good choice or not depends solely on the needs of the app you're developing.
  4. You can enhance the View class with getters, or even extra properties, as needed. In this example I added the mealSizeIcon getter.

Remember that issue we mentioned in the first part of this article with regards to simplifying your component? What you place in Views is one of the things that will help you write dumber, simpler components. In many cases this might be a more convenient solution than creating smaller and smaller components (see tips and tricks below for more details).

Perhaps now you're wondering, how we're supposed to get these View objects into the components? Or even, where are these Views instantiated? If you are thinking this, then you're on the right track.

Use cases

If primary is what connects domain, secondary and UI, then use cases are the roads thanks to which that connection happens. Whenever the UI needs to access anything from the domain, there's a use case to provide it. Let's see how that looks:

import type { RecipeRepository } from "@/domain/recipe/repository/RecipeRepository";
import type { UserId } from "@/domain/user/types";
import { RecipeView } from "@/primary/recipe/RecipeView";

export class GetRecipesUseCase {
  constructor(private readonly recipeRepository: RecipeRepository) {}

  async execute(userId: UserId): Promise<RecipeView[]> {
    const recipes = await this.recipeRepository.getRecipes(userId);

    return recipes.map(RecipeView.fromDomain);
  }
}
Enter fullscreen mode Exit fullscreen mode

Source.

On its most basic form, a UseCase will execute a repository method and return to the UI the Views of those domains. A few things worth noting here:

  1. UseCases are constructed with repositories, not with the classes that implement them. (This is the D in SOLID: Depend upon abstractions, not concretions).
  2. UseCases have a single public method "execute", but can have as many private methods as needed.
  3. This is the only place where domain methods are allowed to be executed. Requesting the mutation of domain objects only happens within use cases.
  4. UseCases always return Views (or void).

Even though this example doesn't show it, use cases can become quite stuffed with logic. When there are relationships between domains that need to be displayed, UseCases can be constructed with as many repositories as needed to handle it. This because you will want to design your use cases to be as convenient as possible for your UI. This is yet another way to keep your components dumb and simple.

Imagine, for example, that you want to show a list of recipes that can be cooked with the seasonal ingredients. For that we may require the IngredientRepository to get the list of ingredients of the current season, and the RecipeRepository would then be called with a filter containing only those ingredients. All that logic could reside in the corresponding GetSeasonalRecipes use case.

When having several use cases requiring several repositories, you may need to be careful to avoid circular dependencies.

Service or UseCase index

You will end-up with a good amount of use cases in each of your domains. Your use-cases directory should thus contain an index of all the use cases to provide easy access to all of the use cases of a domain. This index takes the form of a class that we have called a "Service". This is how that service could look like:

import type { RecipeRepository } from "@/domain/recipe/repository/RecipeRepository";
import type { RecipeToSave } from "@/domain/recipe/types";
import type { UserId } from "@/domain/user/types";
import type { UserRepository } from "@/domain/user/repository/UserRepository";
import { CreateRecipeUseCase } from "@/primary/recipe/use-cases/CreateRecipeUseCase";
import { GetRecipesUseCase } from "@/primary/recipe/use-cases/GetRecipesUseCase";

export class RecipeService {
  private getRecipesUseCase: GetRecipesUseCase;
  private createRecipeUseCase: CreateRecipeUseCase;

  constructor(
    private readonly recipeRepository: RecipeRepository,
    private readonly userRepository: UserRepository
  ) {
    this.getRecipesUseCase = new GetRecipesUseCase(recipeRepository);
    this.createRecipeUseCase = new CreateRecipeUseCase(
      recipeRepository,
      userRepository
    );
  }

  async getRecipes(userId: UserId) {
    return await this.getRecipesUseCase.execute(userId);
  }

  async createRecipe(form: RecipeToSave) {
    return await this.createRecipeUseCase.execute(form);
  }
}
Enter fullscreen mode Exit fullscreen mode

Source.

As you can see, the Service just indexes the 'execute' functions of the use cases. This way, when you use them in your framework, you don't need to inject the use cases one by one. You can simply inject the Services. Plus, it's way nicer to be able to call recipeService.getRecipes(userId) in your components, rather than getRecipesUseCase.execute(userId).

Secondary

In many places we have used the Repositories interfaces to reference functions that are supposed to provide us with domain objects. We have yet to implement any of those repositories. We'll do that next.

Resource

All communication your application has with the external world is implemented by the Resource class. And when I say all, I mean all. When a component needs some data to display, it doesn't care whether that data comes from a REST API, a GraphQL API, local storage, the state management store, WebSockets, edge functions, firebase, supabase, cookies, IndexedDB, or anything else. The resource is the only one that needs to deal with those issues.

Your resource will implement the Repository defined in the domain. And it will do whatever it needs to do to fulfil that contract. And it's up to you, the frontend dev, to make sure that it does this in the most efficient way possible. You'll notice however, that this task becomes much simpler now that you have only one place where you need to think about it.

Let's start with a simple example:

import type { RecipeRepository } from "@/domain/recipe/repository/RecipeRepository";
import type { UserId } from "@/domain/user/types";
import type { Recipe } from "@/domain/recipe/Recipe";
import type { ApiRecipe } from "@/secondary/recipe/ApiRecipe";
import type { RestClient } from "@/secondary/RestClient";

export class RecipeResource implements RecipeRepository {
  constructor(private readonly restClient: RestClient) {}

  async getRecipes(userId: UserId): Promise<Recipe[]> {
    const apiRecipes = await this.restClient.get<ApiRecipe[]>(
      `/users/${userId}/recipes`
    );

    return apiRecipes.map((apiRecipe) => apiRecipe.toDomain());
  }

  async getFavoriteRecipes(userId: UserId): Promise<Recipe[]> {
    //
  }

  async createRecipe(userId: UserId, form: RecipeToSave): Promise<Recipe> {
    //
  }

  async updateRecipe(recipeId: RecipeId, form: RecipeToSave): Promise<Recipe> {
    //
  }

  async deleteRecipe(recipeId: RecipeId): Promise<void> {
    //
  }
}
Enter fullscreen mode Exit fullscreen mode

Full source code.

What's important here is the RecipeResource implements RecipeRepository part. You'll see a Typescript error in your IDE until all the methods in the Repository are implemented in the Resource.

In the simple example shown here, the RecipeResource only fetches information from a REST API. And so it's constructed only with the RestClient (which is just a wrapper over fetch). If later we'd like to replace fetch with Axios or some other HTTP client, we'd be able to do so without interfering with the Repository. Also, if the Resource would later require to connect with a GraphQL API, for example, you can simply add your own graphQLClient to the constructor and use it.

API adapters

The response that the API provides will hardly ever match exactly with your domain in the frontend. An adapter is what will allow you to transform the response to domain objects.

Here's an example:

export class ApiRecipe {
  constructor(
    public readonly id: RecipeId,
    public readonly name: string,
    public readonly ingredients: ApiIngredient[],
    public readonly instructions: string,
    public readonly portions: number,
    public readonly updatedAt: string
  ) {}

  toDomain(): Recipe {
    const ingredients = this.ingredients.map((ingredient) =>
      ingredient.toDomain()
    );

    return Recipe.fromProperties({
      id: this.id,
      name: this.name,
      ingredients: ingredients.map((ingredient) => ingredient.properties),
      instructions: this.instructions,
      portions: this.portions,
      updatedAt: new Date(this.updatedAt),
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Source.

In this example we transform the updatedAt string sent by the API into a Date instance as expected by the domain.

The client will automatically transform the API response into instances of ApiRecipe. That's why we can safely type the response with restClient.get<ApiRecipe[]>, and use it in apiRecipes.map((apiRecipe) => apiRecipe.toDomain())

Store

Your components don't get direct access to the state management library. All data the component needs is called from a service, which calls a use case, which interacts with with repositories implemented by resources. It's then that the resource interacts with the store on behalf of components.

Let's expand on our previous example to see how that works. Say we want to store the recipes in the store the first time we hit the API. And we also want to avoid hitting the API again if the recipes are already stored:

export class RecipeResource implements RecipeRepository {
  constructor(
    private readonly restClient: RestClient,
    private readonly store: RecipeStore
  ) {}

  async getRecipes(userId: UserId): Promise<Recipe[]> {
    const recipesInStore = this.store.recipes;
    if (recipesInStore.length !== 0) {
      return recipesInStore.map(Recipe.fromProperties);
    }

    const apiRecipes = await this.restClient.get<ApiRecipe[]>(
      `/users/${userId}/recipes`
    );
    const recipes = apiRecipes.map((apiRecipe) => apiRecipe.toDomain());
    this.store.saveRecipes(recipes.map((recipe) => recipe.properties));

    return recipes;
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

To achieve this, the RecipeStore is passed to the constructor. The getRecipes method now first checks to see if the recipes are already in the store. If they are, it returns the domain objects instantiating them from the stored data. If there are no recipes stored, then we fetch them, and then save them in the store so that the next time the function is called it returns the stored recipes. That's about it, really.

As you can see, with this approach there will never be an API call made in the actions of your store. All you will ever do with the store is save and retrieve data, just as intended.

Caveat
There is a special case in which the most convenient solution is to have your component react to changes in the store directly. I am not yet sure what's the best way to accomplish this in a way that's compliant with the principles of this architecture. However, in my experience this is really the exception. You'd need to have 2 or more sibling components with interdependent data being displayed in the same page. In 99% of the cases you're good to go with getting the data once from the store and having your framework's internal reactivity system handle the rest via props, component state, events, etc. In any case, there is probably a compliant way to do this (probably using Pinia's or Vuex subscribe mechanism) but I haven't figured it out yet. If you figure it out, please let me know!

UI

We're finally here! Now we get to talk about framework code! We have all we need to invoke the power of our domain and use it in our components. Unfortunately I won't be able to tell you the best way to do this in every frontend framework, but I'll tell you how I've learned to do it in Vue, and I'll tell you what you need to take into consideration to make the best decision in your framework of choice.

In Vue, we use the Provide / Inject mechanism to give components access to the services they need. You can either provide use app-level provide, or component level provide if you know where in the component tree to do so.

import { createApp } from "vue";
import { createPinia } from "pinia";

import App from "./App.vue";

import { RecipeResource } from "@/secondary/recipe/RecipeResource";
import { RestClient } from "@/secondary/RestClient";
import { useRecipeStore } from "@/secondary/recipe/RecipeStore";
import { RecipeService } from "@/primary/recipe/use-cases";
import { UserResource } from "@/secondary/user/UserResource";

// Services
const pinia = createPinia();
const restClient = new RestClient();

const userResource = new UserResource();

const recipeStore = useRecipeStore();
const recipeResource = new RecipeResource(restClient, recipeStore);
const recipeService = new RecipeService(recipeResource, userResource);

// Setup
const app = createApp(App);

app.use(pinia);
app.provide<RecipeService>("recipeService", recipeService);

app.mount("#app");
Enter fullscreen mode Exit fullscreen mode

As you can see, all your components really need access to is the services.

After that, you can use them in components at your leisure:

export default defineComponent({
  setup() {
    const recipeService = inject<RecipeService>("recipeService")!;

    return {
      recipeService,
    };
  },

  data() {
    return {
      recipes: [] as RecipeView[],
    };
  },

  async created() {
    this.recipes = await this.recipeService.getRecipes("me");
  },
});
Enter fullscreen mode Exit fullscreen mode

The injection is preferred to a simple import because it will greatly simplify testing. Like this you can easily create your own mock objects and inject them into the components while testing. This way you can easily test all possible data states being introduced into your components. For React, maybe using Context to include the services would be the way go, but I'll let you be the judge of that.

Extra

If you've made it this far, you already have all the elements you need to start your own journey in domain driven frontend architecture. Now I'll just share a few noteworthy lessons I've learned on my personal journey.

Data flow diagram

Here's a neat diagram to remember the main ideas of how data flows in architecture:

Image description

In blue the API objects, in red the view objects, and in purple the domain objects.
Data can flow both ways (incoming and sending), but should always follows the same path. Incoming data from an external source (yes, that includes the Store), takes the form of API objects. The resource receives those, instantiates domain instances, and passes them forward to the use case. The use case then returns view objects to the components.

Note that the domain takes some of the space of secondary and primary. This is to represent the influence of domain in those areas: the resource implements the domain repository, and use cases are the only places where domain methods are called.

Tips and tricks

Sometimes the hardest part of implementing this architecture will be deciding what goes in the domain, what goes in the view, and what can legitimately stay in the components. The way to think about this question with regards to your frontend code differs greatly from the way you think about it with regards to the backend.

How to know what is part of the Domain and what isn't

In the book, Scott Millett and Nick Tune ask us to think about the most important thing of the application, the part that is actually providing value. The whole point of DDD is to create the conditions in which devs can focus most of their attention into that thing.

That is great and you should definitely be doing that. On the day-to-day, however, when you receive a task to implement a new feature and you need to decide which properties and methods to include in your domain model, you will need a bit of a more concrete answer. The answer will vary from company to company and from project to project. What if what makes your solution special is the UX/UI, instead of the business intelligence powering it? Does that means that UX/UI related properties will go into the domain? 🤷 One would have to be placed in that position to be able to answer that.

The one thing I learned (the hard way) though is that I should never assume that whatever the backend provides is what needs to be in the domain model in the front. Doing this misses the point of this exercise. As a frontend developer, I encourage you to don't be afraid to rethink their decisions. See if/where/how all of the properties you receive from the back are being used. Implement in your domain model only what you need in order to accomplish your goals, and leave everything else out. If later someone needs to add other properties to the domain model to augment your functionality, let them do it, and that's a good thing, because you want that person to think about the properties he's going to need on his own. You don't need to think for him now.

Personally, I have found it useful to think that, if the decision to have it in the application comes from the Product Owner (the product person, ideally the one with the whole view of the problem and the solution), then it usually will go in the Domain. If the decision comes from the designer (or whoever decides the UI), then you can probably place it in the View.

How to know what to place into Views and what to leave for components

To answer this, we as frontend devs will have to (again) think about the application as a whole, but this time from a UX/UI perspective. The questions you may have have while thinking what to place on the domain will probably be directed to the the product person. The questions you may have while thinking what to put in the View will likely be directed to the UI/UX designer.

Pay attention to the intended design and think what information and or visual queues about the information always appear together. An example I used earlier about my cookbook app: the designer may decide that every time the difficulty of a recipe is displayed, then a specific icon is shown to represent that difficulty. If that's the case always then that's a strong argument to placing a reference to that icon next or along with the recipe difficulty property. Another good example that I see often is when displaying the status or state of a process (i.e., is a recipe a draft, is a bank transfer ongoing, is an appointment cancelled). Usually when conveying such information designers choose to always use some specific visual queues. Those could probably be included in your View. Elements that are particular to a single component can stay in the component.

Where the power of the View really comes to light is when a company has a consistent design system in place. If that's the case, you may find all the answers you need simply by going through and understanding the design system. For example, if in the system you see that dates are always displayed in one or two formats, then you can include those formats in your Views. Transforming the date into the proper format doesn't need to be done by the component in this case.

I can't speak highly enough about the value of a well-maintained design system for frontend devs that implement this architecture. The agility you and your team can achieve, even in very complex solutions, is just unmatched.

So many classes everywhere 😩 is there a more functional way to go about implementing this?

I'm no expert in functional programming, but yes, I believe there is. Truth is, I'm not a big fan of classes myself (or at least I wasn't). The only thing I believe truly benefits a lot from having the form of a Class is the domain model. I believe it's important to show explicitly which properties are private and readonly, and which ones aren't. You can probably figure out compliant ways to implement the rest of the architecture with pure functions, if that's your thing. And if you do, hit me up, I'd love to take a look!

Conclusion

I’ve never been as happy coding as I am when I'm implementing this architecture (or at least its most valuable lessons). To me, it's meant the difference between randomly writing commands in the screen until it works, and actually feeling in control over what's happening. That's the reason I'm writing this long-ass article. If I can help anyone feel the same joy, I've done my part.

That being said, this architecture, and indeed DDD in general, is not for everybody and every project. Millett and Tune say that if you're building another todo list app, then you probably don't need DDD. I think this is because there's no complexity to be managed in a todo list. Any developer can come to the code and understand everything, because everyone knows how todo lists work. However, if you're building a unique todo list, with features that make it special to the users, then perhaps the teachings of DDD will prove useful to you.

For me, the main teachings are:

  • No time is wasted when thinking about how to manage the increasing complexity of your codebase. The discussions I've had with my team around the subject (all of which have informed the views I've expressed in this article) have been the most enriching of my career, and are at the base of the best code I've been able produced so far.
  • No time is wasted that is spent thinking about the domain. Under domain-driven architecture, a day at work of implementing a new, complex feature looks quite different for me from a day without it. In the former, follow me for a day and you'll see me staring at the ceiling, going for coffee, looking at the design, talking short walks, and talking to team members, not writing a single line of code for 5 hours, just thinking... and them come back and write a solution I can feel proud of in under 2 hours. In the latter, you'll probably see me writing code for 2 days, hitting inexplicable errors, not talking to anyone, and providing a solution for which I'm just glad I don't have to deal with anymore.

  • In your codebase, define clear responsibilities, and when you spot new responsibilities popping up, set them apart as well.

  • Protect the model. Just like how in the flux design pattern any change to the store is constrained to a particular way, in the same way changes to domain objects should also be constrained. Use the language features available to you to inform the dev about these (useful) constraints.

  • Write tests. I don't have the occasion to discuss testing here, but it's a big, inseparable part of DDD, (and should be the same for the coding of any serious application using no matter which design). Listen to Uncle Bob discuss himself this here.

  • All projects have an architecture. Not choosing an architecture is an architecture decision. Such decision should always be made consciously, weighing the pros and cons, and making the reasoning behind the final decision known to everyone. Speaking of pros and cons...

Pros and cons of this architecture

To finish, here's a quick rundown of the pros and cons I've been able to gather from my experience.

Pros

  • Places devs in a position in which they have to think about the domain, which is just another way of saying "think about the best solution possible to the business problem at hand".
  • Produces maintainable, organized, robust, testable (and hopefully, tested) code.
  • More rewarding coding experience, and thus a more rewarding working experience (personally at least).

Cons

  • Increased entry cost. This is particularly new for most frontends. It took me quite some time to get a decent grasp of the how's and why's of all the architecture decisions that I suddenly needed to start considering while coding.
  • Increased boilerplate. That's been for a while my biggest issue with this particular flavour of domain-driven architecture for the frontend. In my personal projects I have made certain decisions that would help decrease the boilerplate, but I've since learned that what I save from writing less boilerplate doesn't make up for the inconsistencies that get introduced into the codebase due to these decisions. Currently, the way I tackle the boilerplate issue is by creating as many snippets and file templates as I need that would write the boilerplate for me so I don't have to.

Top comments (3)

Collapse
 
imlaoji profile image
LCG

Hello, why do you need a domain object type? What is its role? Can you directly store the domain object as a type in the store?

Collapse
 
blindpupil profile image
Cesar Martinez

I struggled with this too, the first time I was exposed to this approach. The practical role of the domain object is to be the model which controls all possible mutations to its instances. That domain class is the only place where domain objects are allowed to mutate (and those mutations can only be called from use cases). See an example in the Ingredients domain the repo.
Only use cases are allowed to access domain objects. Having a single place where domain objects can mutate, and a single way to do it, is one way to manage increasing complexity and maintain control over your code.
So ideally you don't store domain objects in the store, because having them in the store will give devs the (wrong) impression that they are allowed to access domain objects in components (specially with how easy it is to pull store data into components from Vuex or Pinia stores). The domain is better protected from unexpected changes if they are not saved in the Store.
Remember your Store should ideally just be another tool for your Secondary layer (same as API and LocalStorage). So what you hold in your Store should be something that your secondary can transform into domain objects before passing them on to the use case that requested it.

Collapse
 
imlaoji profile image
LCG

It is very clear and I now understand why it is designed that way. Thank you again for sharing such an excellent article, I am very interested in front-end DDD and I am planning to practice it in my project.