Vue.js and layered architecture: bringing business logic to services

When you need to make the code in a project flexible and convenient, dividing the architecture into several layers comes to the rescue. Let’s take a closer look at this approach and alternatives, as well as share recommendations that can be useful to both beginners and experienced developers of Vue.js, React.js, Angular.

In the old days, when jQuery first appeared, and frameworks for server-side languages ​​were only read in rare news, web applications were implemented entirely in server-side languages. Often, the MVC (Model-View-Controller) model was used for this: the controller (controller) accepted requests, was responsible for the business logic and models (model) and passed data to the view (view), which rendered HTML.

Object-oriented programming (OOP) at that time was just beginning to take shape, so developers often intuitively decided where and what code to write. Thus, in the development world, such a concept arose as “Divine objects”, Who were initially responsible for almost all the work of individual parts of the system. For example, if there was an entity “User” in the system, then the User class was created and all the logic that was somehow connected with users was written in it. Without splitting into any other files. And if the application was large, then such a class could contain thousands of lines of code.

Then the first frameworks appeared, it became more convenient to work with them, but they did not teach how to correctly lay the structure and architecture of the project. And developers continued to write thousands of lines of code in the controllers of the newfangled frameworks.

1. There is a way out

As you know, Vue.js, React.js and other similar frameworks are based on components. That is, by and large, an application consists of many components that can contain both business logic and presentation, and much more. Thus, developers in many projects write all the logic in components and these components, as a rule, begin to resemble those divine classes from the past. That is, if a component describes some large part of the functionality with a large amount of (possibly complex) logic, then all this logic remains in the component. Dozens of methods and thousands of lines of code appear. And if you take into account the fact that, for example, in Vue.js there are still concepts such as computed, watch, mounted, created, then the logic is also written to all these parts of the component. As a result, in order to find some part of the code responsible for clicking on the button, you need to scroll through a dozen screens of js code, running between methods, computed and other parts of the component.

Around 2008, a “layered” architecture was proposed for the backend. The basic idea behind this architecture is that all application code should be broken down into specific layers that do some work and don’t really know about the other layers.

With such a breakdown, the application becomes much easier to maintain, write tests, search for critical areas, and generally read the code.

This is how the code is divided into layers and will be discussed, but in relation to frontend frameworks such as Vue.js, React.js and others.

The original theory of “layered” architecture as applied to the backend has many limitations and rules. The idea of ​​this article is to adopt exactly the division of the codebase into layers. It can be schematically depicted like this.

2. Creation of a convenient application architecture

Consider an example where all the logic is in one component.

2.1. Component logic

The component in question is responsible for working with collages, in particular for duplicating, restoring and deleting. It already uses some services, but still there is a lot of business logic in the component.

methods: {
    duplicateCollage (collage) {
      this.$store.dispatch('updateCollage', { id: collage.id, isDuplicating: true })
      dataService.duplicateCollage(collage, false)
        .then(duplicate => {
          this.$store.dispatch('updateCollage', { id: collage.id, isDuplicating: false })
        })
        .catch(() => {
          this.$store.dispatch('updateCollage', { id: collage.id, isDuplicating: false })
          this.$store.dispatch('errorsSet', { api: `We couldn't duplicate collage. Please, try again later.` })
        })
    },
    deleteCollage (collage, index) {
      this.$store.dispatch('updateCollage', { id: collage.id, isDeleting: true })
      photosApi.deleteUserCollage(collage)
        .then(() => {
          this.$store.dispatch('updateCollage', {
            id: collage.id,
            isDeleting: false,
            isDeleted: true
          })
          this.$store.dispatch('setUserCollages', { total: this.userCollages.total - 1 })
          this.$store.dispatch('updateCollage', {
            id: collage.id,
            deletingTimer: setTimeout(() => {
              this.$store.dispatch('updateCollage', { id: collage.id, deletingTimer: null })
              this.$store.dispatch('setUserCollages', { items: this.userCollages.items.filter(userCollage => userCollage.id !== collage.id) })
 
              // If there is no one collages left - show templates
              if (!this.$store.state.editor.userCollages.total) {
                this.currentTabName = this.TAB_TEMPLATES
              }
            }, 3000)
          })
        })
    },
    restoreCollage (collage) {
      clearTimeout(collage.deletingTimer)
      photosApi.saveUserCollage({ collage: { deleted: false } }, collage.id)
        .then(() => {
          this.$store.dispatch('updateCollage', {
            id: collage.id,
            deletingTimer: null,
            isDeleted: false
          })
          this.$store.dispatch('setUserCollages', { total: this.userCollages.total + 1 })
        })
    }
}

2.2. Creating a service layer for business logic

To begin with, you can introduce a service layer into the application that will be responsible for the business logic.

One of the classic ways to split logic in some way is to divide it into entities. For example, almost always the project has a User entity or, as in the described example, a Collage. Thus, you can create a services folder and in it – the user.js and collage.js files. Such files can be static classes or simply return functions. The main thing is that all the business logic associated with the entity is in this file.

services
  |_collage.js
  |_user.js

The collage.js service should contain the logic for duplicating, restoring and deleting collages.

export default class Collage {
  static delete (collage) {
    // ЛОГИКА УДАЛЕНИЯ КОЛЛАЖА
  }
 
  static restore (collage) {
    // ЛОГИКА ВОССТАНОВЛЕНИЯ  КОЛЛАЖА
  }
 
  static duplicate (collage, changeUrl = true) {
    // ЛОГИКА ДУБЛИРОВАНИЯ КОЛЛАЖА
  }
}

2.3. Using services in a component

Then the component will only need to call the corresponding service functions.

methods: {
  duplicateCollage (collage) {
    CollageService.duplicate(collage, false)
  },
  deleteCollage (collage) {
    CollageService.delete(collage)
  },
  restoreCollage (collage) {
    CollageService.restore(collage)
  }
}

With this approach, the methods in the component will consist of one or several lines of code, and the logic associated with collages will be encapsulated in the corresponding collage.js file, and not spread over a huge component, respectively, it will be easier to search for the desired code, maintain it and write tests. Another plus of this approach is that the code from services can be reused anywhere in the project.

Also, many developers on the way to a convenient architecture move API method calls to a separate file (files). This is exactly the creation of a layer of API calls, which also leads to the convenience and structure of the code.

import axios from '@/plugins/axios'
 
export default class Api {
 
  static login (email, password) {
    return axios.post('auth/login', { email, password })
      .then(response => response.data)
  }
 
  static logout () {
    return axios.post('auth/logout')
  }
 
  static getCollages () {
    return axios.get('/collages')
      .then(response => response.data)
  }
  
  static deleteCollage (collage) {
    return axios.delete(`/collage/${collage.id}`)
      .then(response => response.data)
  }
  
  static createCollage (collage) {
    return axios.post(`/collage/${collage.id}`)
      .then(response => response.data)
  }
}

3. What and where to take out?

The question of what exactly and where to take out is impossible to answer unequivocally. Alternatively, you can split your code into three conditional parts: business logic, logic, and presentation.

Business logic is everything that is described in the application requirements. For example, technical specification, documentation, designs. That is, everything that is directly related to the application domain. An example would be the method UserService.login () or ListService.sort ()… For business logic, you can create a service layer with services.

Logic is the code that is not directly related to the application domain and its business logic. For example, creating a unique string or searching for an object in an array. For the logic, you can create a layer of helpers: for example, the helpers folder and the files string.js, converter.js and others in it.

View – everything that is directly related to the component and its template. For example, changing reactive properties, changing states, etc. This code is written directly in the components (methods, computed, watch, and so on).

login (email, password) {
  this.isLoading = true
  userService.login(email, password)
    .then(user => {
      this.user = user
      this.isLoading = false
    })
}

Further, in the components, you will need to call services, and the services will use helpers. This approach will provide us with lightweight, small and simple components, and all the logic will be in logically understandable files.

If services or helpers begin to grow, then the entities can always be divided into other entities. For example, if the user in the application has a small functionality of 3-5 methods and a couple of methods about user orders, then the developer can bring all this business logic into the user.js service and write all this business logic in the user.js service. If the user’s service has hundreds of lines of code, then everything related to orders can be transferred to the order.js service.

4. From simple to complex

Ideally, you can make an OOP architecture, in which, in addition to services, there will also be models. These are classes that describe the entities of the application. The same User or Collage. But they will be used instead of regular data objects.

Consider a list of users.

The classic way to display the full names of users looks like this.

<template>
<div class="users">
  <div
    v-for="user in users"
    class="user"
  >
    {{ getUserFio(user) }}
  </div>
</div>
</template>
 
<script>
import axios from '@/plugins/axios'
 
export default {
  data () {
    return {
      users: []
    }
  },
  mounted () {
    this.getList()
  },
  methods: {
    getList() {
      axios.get('/users')
        .then(response => this.users = response.data)
    },
    getUserFio (user) {
      return `${user.last_name} ${user.first_name} ${user.third_name}`
    }
  }
}
</script>

The function of obtaining the full name can be taken out in order to easily and simply reuse if necessary.

The first step is to create a User model.

export default class User {
  constructor (data = {}) {
    this.firstName = data.first_name
    this.secondName = data.second_name
    this.thirdName = data.third_name
  }
 
  getFio () {
    return `${this.firstName} ${this.secondName} ${this.thirdName}`
  }
}

The next step is to import this model into the component.

import UserModel from '@/models/user'

Using the service, get a list of users and convert each object in the array to an object of the class (model) User.

methods: {
   getList() {
     const users = userService.getList()
     users.forEach(user => {
       this.users.push(new UserModel(user))
     })
   },

Thus, in the template or in the methods, you will not need to create any separate functions to work with the user object, they will already be inside this object.

<template>
<div class="users">
  <div
    v-for="user in users"
    class="user"
  >
    {{ user.getFio() }}
  </div>
</div>
</template>

On the question of what logic to bring out in the model, and what in services. You can put all the logic in services, and call services in the models. Or, in models, you can store logic related directly to the entity of the model (the same getFio ()), and store the logic of working with arrays of entities in services (the same getList ()). As it will be more convenient.

5. Conclusion

If in a project a large amount of logic is stored in components, there is a risk of making them difficult to read and complicating further reuse of logic. In such cases, you can introduce “layers” to expose this logic: for example, a service layer for business logic, a helper layer for the rest of the logic. Inside the component, you should leave the logic that relates directly to it and its template.

Also, for convenience, you can create layers for operations with sessions, interceptors api, global error handlers – whichever suits you best. This way, you keep the components small and simple, and the logic is stored where it is easy to find and reuse anywhere in the project.

Thanks for attention! We will be glad to answer your questions.

Similar Posts

2 Comments

  1. Hello, great thoughts.
    The only problem left is how to combine controllers like the User class with reactivity.
    For example, if firstName has changed, getFio() will probably not update the view.
    Do you have any thoughts about this?

  2. Let’s say i have one nested json response from api
    appointment: {
    id: 000,
    title: “some title”,
    appointment_user: {
    uid: “0928293821”,
    first_name: “John”,
    last_name: “doe”
    }
    }

    in such situation how can I nest my js models so that it will automatically load nested object?

Leave a Reply

Your email address will not be published. Required fields are marked *