SECRET OF CSS

Domain-Driven Architecture in the Frontend, Part 2 | by Cesar Martinez | Jun, 2022


How your domain code interacts with your frontend application

Photo by Anders Jildén on Unsplash

(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, its 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.

The reason we can’t talk about your framework code just yet is that 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.

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";
}
}
}

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 that domain instances can have getters required by the View that doesn’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, then you’re on the right track.

If the 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);
}
}

Source.

In 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 is 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 those ingredients. All that logic could reside in the corresponding GetSeasonalRecipes use case.

Another common example: if your app requires you often to provide the logged-in user id for the requests, you can decide to have the UseCase make that request to get that id (using the UserRepository for instance), instead of having the component take care of it.

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 what 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);
}
}

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).

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.

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 fulfill 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> {
//
}
}

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 also 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 aver 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),
});
}
}

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())

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 repositories implemented by resources. It’s then the resource that 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;
}
// ...
}

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 on 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!

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");

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");
},
});

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.

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.

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 on 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 mean 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 your 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 while thinking about what to place on the domain will probably be directed to the product person. The questions you may have while thinking about what to put in the View will likely be directed to the UI/UX designer.

Pay attention to the intended design and think about 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 for placing a reference to that icon next to 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 canceled). 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 data 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 truly believe gains a lot from having the form of a Class is the domain model.

I believe it’s important to make explicit 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!



News Credit

%d bloggers like this: