Domain-Driven Design in Vue.js

Part 2 of 3 in our Modular Vue.js Applications series.
Written by Filip Rakowski

In the previous article I introduced the concept of Domain-Driven-Design and showed how it can be utilized within Nuxt with their modules. In this part I want to share with you how we applied the same concept in Vue Storefront 1 which is a regular, Vue 2 application.

What do we want to build?

In short words - we want to build something similar to Nuxt modules in a regular Vue.js application. Of course, we will cut down a lot of functionalities and focus only on the ones that we need for this particular use case.

Such module should meet the following requirements:

  • Communicate with the rest of the application through a single entry point just like a Nuxt module
  • Connect with the vital parts of your application like router, vuex, and the app instance so we don’t need to write our code outside of the module
  • Don’t rely on imports from other modules (all communication should be possible through the entry point) to avoid tight relations between modules

This is more or less how we used the Nuxt module in the previous article. Now let's see how we can create this architecture, starting with the module API itself.

What should the module look like?

In Vue Storefront we decided to define each module as a function. The module function has access to all vital parts of the application through a context object:

function categoryModule({ app, router, store }) {
// we can also use app.$router, app.$store but this approah is more readable to me
}

TIP: Putting all function parameters in a single object makes it easier to maintain. It’s easier to remove or add new properties into such functions in the future because their order doesn’t matter.

Let’s quickly review all the parameters and for what we would need them:

The app is our root Vue instance. We will need it for:

  • installing Vue plugins

    Vue.use(plugin)

    CAUTION: For the plugins that are adding properties to the Vue.prototype , it’s important that we register the module before the Vue instance initialization!

  • registering global components/directives

Vue.component('ComponentName', ComponentInstance)
Vue.directive('directiveName', directiveInstance)

CAUTION! Avoid directly adding properties to global configuration like this:

Vue.myGlobalProperty

You can accidentally create dependencies between modules (which is something we try to avoid) if you use these properties in other modules.

The router instance will be used for

  • adding routes
router.addRoutes(routes)
  • registering navigation guards
router.beforeEach()
router.afterEach()

We will use store to register Vuex modules

store.addModule('ModuleName', moduleInstance)

That’s all we need for our module system to integrate with the app.!

Now let’s take a look at the Nuxt module from the previous article:

// index.js
module.exports = function ProductCatalogModule (moduleOptions) {
  this.extendRoutes((routes) => {
    routes.unshift({
      name: 'product',
      path: '/p/:slug/',
      component: path.resolve(themeDir, 'pages/Product.vue')
    });
  );
  // we can't register stores through Nuxt modules so we have to make a plugin
  this.addPlugin(path.resolve(__dirname, 'plugins/add-stores.js'))
}

// plugins/add-stores.js
import CategoryStore from '../stores/category.js'
import ProductStore from '../stores/product.js'

export default async ({ store }) => {
  store.registerModule(CategoryStore)
  store.registerModule(ProductStore)
};

This simple Nuxt module adds a single route and then registers two Vuex modules. It looks like we have everything needed to recreate it with our new API. Let’s try!

import ProductPage from './pages/Product.vue'
import CategoryStore from '../stores/category.js'
import ProductStore from '../stores/product.js'

export function categoryModule({ app, router, store }) {
  router.addRoutes([{
    path: '/p/:slug/',
    component: ProductPage 
  }]);  
  store.registerModule('category', CategoryStore)
  store.registerModule('product', ProductStore)
}

And thats it, even less code than with Nuxt!

Under the hood

Now that we know how our API will look like let’s start implementing the underlying business logic. For now the module is just a function and we have to create the bindings between this function and the rest of the app.

Learn Vue.js With Vue School

Let’s see how easy it is to create a registerModule function!

// modules.js
// router instance
import router from './router.js'
// store instance 
import store from './store.js'
// vue instance
import Vue from 'vue'

export function registerModule(module) {
  module({ app: Vue, store, router })
}

We’re just passing the app, store and router object into the module function.

Now when we want to register our catalog module we’re doing it like this in the entry point of our application (or somewhere else if the module is not including routes):

// main.js
import { CatalogModule } from './modules/catalog'
import { registerModule } from './modules.js'
import Vue from 'vue'

// instantiate router, store and plugins

const app = new Vue({ 
// options 
))

registerModule(CatalogModule)

And that’s all! now we have a working simple module system in the regular Vue application! This concept is really powerful and can be easily extended if needed.

Communicating between modules

Once your app will become a little bit more complex you’ll quickly realize that some level of communication between modules is inevitable. In Vue Storefront we wanted modules to be notified about certain events in the app like user log in/log out actions.

There are many ways and approaches to this topic and perhaps I’ll write a separate article about this in the future but today I will share the simplest solution to that problem - Event Bus.

We will declare a set of global events that could happen in our app that other modules can listen to and emit. By not putting these events declaration inside any module we’re making sure that they are always accessible and not tied to the implementation details of our auth module.

Let’s start with the Event Bus implementation. If that's the first time you hear this term don’t get scared. In Vue world Event Bus is just a Vue instance that is used to emit and listen to events.

// eventBus.js
import Vue from 'vue'

const eventBus = new Vue()
export default eventBus 

That’s the whole declaration! Now we can use this Event Bus anywhere in our app:

// emit an event
bus.$emit('init')
// we can perform some actions after the event is being emitted by listening to it
bus.$on('init', () => console.log('Hello world!')

The problem I have with Vue events system is that its using strings as identifiers. It’s super hard to debug them if you accidentally make a typo somewhere.

Since we already know what events we would like to be globally available in our app we can explicitly declare them:

// eventBus.js
import Vue from 'vue'

const eventBus = new Vue()

const EVENT_TYPES = { logIn: 'logIn', logOut: 'logOut' }

export default {
  $emit: {
    logIn: (payload) => eventBus.$emit(EVENT_TYPES.logIn, payload)
    logOut: (payload) => eventBus.$emit(EVENT_TYPES.logOut, payload)
  },
  $on: {
    logIn: (callback) => eventBus.$on(EVENT_TYPES.logIn, callback)
    logOut: (callback) => eventBus.$on(EVENT_TYPES.logOut, callback)
  }
}

We used EVENT_TYPES enum to map string identifiers into a data objects similarly like we do with mutation types in Vuex.

With this approach we’re not only eliminating the risk of making a typo but also improving Developer Experience with autosuggestions.

import eventBus from './eventBus.js'

eventBus.$emit.logIn()

The only thing that's left is injecting the event bus instance into our module system so it can be accessed in every module:

// modules.js

// router instance
import router from './router.js'
// store instance 
import store from './store.js'
// vue instance
import Vue from 'vue'
// event bus instance
import eventBus from './eventBus.js'

export function registerModule(module) {
  module({ app: Vue, store, router, eventBus })
}

As you can see extending our module system with additional properties is a straightforward process because we used a single object as a parameter we can easily add new properties without introducing breaking changes to already existing modules.

Now, let’s see this Event Bus in action to understand it a little bit better.

In our app we will probably have some kind of authorization module with a `logIOut action in its Vuex store:

export function authModule({ app, router, store, eventBus }) {

  const authStore = {
    // state, mutations, getters...
    actions: {
     // other actions
     logOut({ commit }) {
       // clear token, end session
       eventBus.$emit.logOut()
     }
    }
  }
  store.registerModule('auth', authStore)
}

After all the steps required for the log out are performed we are notifying the rest of our app about the event so it can react to it.

Now the cart module could listen to this event and clear the cart once the user is logged out

export function cartModule({ app, router, store, eventBus }) {
  // register vuex store, routes etc
  eventBus.$on.logIn(store.dispatch('cart/load'))
  eventBus.$on.logOut(store.dispatch('cart/clear'))
}

This way we’re avoiding tight coupling of modules while still allowing them to communicate with each other. If you follow this approach you’ll cut down the complexity of your app by boiling it down into a set of smaller and scoped “sub-applications”.

Summary

Vue is a very flexible framework. Even though it’s not promoting a DDD concepts with the common best practices it’s straightforward to adjust our application to follow this approach.

Let me know in the comments if you like this series and if you’d like me to explore this concept more in the next articles!

Learn Vue.js With Vue School

Leave a Reply

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

Up Next:

Domain-Driven Design in Nuxt Apps

Domain-Driven Design in Nuxt Apps