Skip to main content

Architecture | Building large projects with Vue, Vite and Lerna

Vite

Introduction

Today we’d like to present a blueprint for large Vue JS projects. It uses the new and exciting Vite build tool and Lerna monorepo manager. I’ve built large enterprise projects in a similar way, using Angular, Vue JS, webpack and rollup. Vite, created by Vue JS team, looks very promising so I wanted to give it a try.

There are plenty Vite tutorials and demos, mostly the usual Hello World and Todo apps. But I needed something more useful. I wanted to see whether Vite can replace rollup and webpack in large real-life projects.

TL;DR

Vite is an excellent alternative to mainstream JavaScript build tools. It works smoothly with Vue, and with any other web framework just as well. Combined with Lerna, it works great with large modular applications, while being much faster and easier than competitors. Source code of the example project can be found at https://bitbucket.org/letsdebugit/vite-monorepo-example.

The Blueprint

I’ve built a blueprint for modular Vue JS application, which can serve as a starting point for an actual project. Something along these lines:

Project Structure

The project is organized into the following packages:

  • common - packages with shared code, such as entity model, reusable services and utilities.
  • applets - functional parts of the application, such as customers or inventory. Each functional part is made of two packages: api for API endpoints, and ui for UI views and components.
  • application/server - where all API parts come together and are bundled into back-end, running on NodeJS
  • application/client - where all UI parts come together and are bundled into front-end, running in the browser

I’ve organized the project along vertical business modules. Each module represents distinct business functionality. This is quite unlike traditional layered monolith architecture. Such architectures organize code by its deployment location. There is a back-end layer, middleware layer, client layer etc.

I prefer when all code related to the same business functionality is kept together. When it’s in separate layers, it is much harder to maintain it. Logical architecture of a system does not have to be dictated by physical deployment.

The main application packages are lightweight. They’re nothing more than glue code, bringing together the functional modules. As the project grows, we add more functional modules. But the general architecture remains the same! Codebase grows in size, but not in complexity. Internal dependencies are clear and easy to follow.

This blueprint is only the beginning. You can amend and extend it in many ways. It opens path to microservice architecture, when it becomes necessary. All heavy lifting is already done, because UI and API are modular. It doesn’t take much effort to turn them into standalone micro-frontends and micro-backends.

Even if you don’t need microservices, which is usually the case, this architecture can be useful. I like to be able to develop, test and run my functional modules standalone. Such approach promotes transparent architecture. It helps me focus, and it speeds up daily development.

I hope that many readers will find this article useful. Feel free to contact me, if your organization or team is looking for expertise or advice. I can help kickstart and improve your project, whether it’s a proof-of-concept or enterprise application!

What is Vite?

Vite is a new build tool from creators of Vue JS framework. They have released the first version in April 2020. It immediately created a lot of buzz, while raising a question: do we need another build tool?

The answer is yes. Why? Because leading build tools became unbearably bloated. Vite is a swift and refreshing change. Although created by the Vue tribe, it is not limited to Vue. You can use it to build all kinds of JavaScript code, and it has many cool features.

First, Vite is built around rollup, which until now has been my build tool of choice. Just like rollup, Vite is lean, mean and easy to control, unlike certain alternatives. It is not the right time and place to elaborate on my troubled relationship with webpack. I will only say that traumatic experiences with it are still haunting me at nights ;-)

Needless to say, Vite inherits all the good features of rollup, such as:

  • Simple configuration
  • Great speed
  • Tree-shaking - purging code which is never called
  • Ability to use rollup plugins, which there are plenty

Now the killer feature.

During development Vite doesn’t bundle the code at all! It knows that we no longer develop for Internet Explorer 11. During development it imports and runs ES modules in the browser. This means instant start. Often measured in milliseconds, regardless of how big your codebase is. This gives us efficient hot module reloading. HMR is ability to apply code changes as you type. No need for time-consuming rebuilding and reloading of the entire application. When working with a large project, it saves hours and days spent on mindless staring at terminal.

Only when deploying, we run a full build - which is fast and efficient.

Why using Vite only now?

Excellent like it was, the first release had a major shortcoming. It couldn’t be used to compile libraries - packages used as dependencies by other packages and applications. I’ve managed to hack my way around it, but it wasn’t pretty. This meant no Vite for large projects or monorepos.

The good news - recently released Vite 2 officially supports building libraries. Now we can use it as build engine in large projects as well, also for building server-side code.

Quick introduction to monorepo

This chapter is for readers unfamiliar with concept of monorepo. Others can proceed to the next chapter.

Maintaining large JavaScript projects is hard labour. As they grow, we usually move from monolithic codebases towards modular approach. We organize code into separate packages and manage them with npm, yarn etc.

On the surface, splitting a system into separate packages makes it easier to manage. It also enables sharing code with other teams. But it comes at a heavy price. Creators of NPM didn’t provide for internal dependencies. What are the main problems?

  • Each package requires its own git repo and npm bundle
  • Every change to library packages requires build, git push and npm publish. Then every other package which uses it, requires npm update. Then you repeat this process until you’ve updated the entire tree of dependencies. It is difficult and it costs a lot of time. If not done carefully, it leads to dependency mess and mysterious heisenbugs.

The recently announced NPM 7.0 Workspaces are only a very modest step into right direction. Lerna and other monorepo tools make it all much easier. Most important features of Lerna are:

  • Ability to run scripts across many packages, i.e. build or test
  • Ability to quickly add dependencies to many packages
  • Ability to have internal dependencies without tedious publish and update cycle. Instead, it resolves internal dependencies using symlinks. Any package can now import other packages straight from their source folders. Publishing to npm repository is no longer required.

With Lerna you get all benefits of a single code base together with benefits of a modular project:

  • One git repository contains the entire codebase
  • Code is split into individual packages, each built and tested separately
  • It is easy to import internal packages and reuse code. You publish to npm only when you need to share code with outside world.

This is what monorepo is all about.

Running the project

Initialize the project as follows:

git clone https://bitbucket.org/letsdebugit/vite-monorepo-example.git
cd vite-monorepo-example
npm run initialize

This downloads the project into vite-monorepo-example folder. It then installs all dependencies. It prepares Lerna monorepo and creates symlinks for internal dependencies. All is now ready for build::

npm run build

You will see a rainbow of colorful messages, hopefully ending with this:

Build

This was a production build. It creates /dist folder in each package. You only need two of these to deploy and run the application:

  • Back-end bundle found in /packages/application/server/dist
  • Front-end bundle found in /packages/application/client/dist

You can run the back-end with NodeJS and serve the front-end with a web server. But for development time, it’s enough to open terminal and execute

npm run dev:server

to build and execute the back-end. It will be served at http://localhost:3333 and it looks like this:

Back-End

To test the API, open the above URL in a browser or run curl -s http://localhost:3333. You will see the following output (piped here through indispensable jq for better readability):

Back-End Status

Open another terminal and execute

npm run dev:client

to start serving the front-end. It will be available at http://localhost:3000. Open this URL in a browser and you should see:

Front-End

Application architecture

The project is made of independent vertical modules, each containing a slice of business functionality. Our blueprint has two example modules: Customers and Inventory. Each module contains two packages: api and ui. Build process combines all business modules into two deployables:

  • back-end application which hosts an API
  • front-end application which consumes the API.

Shared packages

Often you want to reuse code between packages. We enable this with shared packages found in /packages/common folder:

  • /packages/common/utilities containing general-purpose functions, useful on both front-end and back-end. Be careful and don’t write here any code which runs only in a browser or only on NodeJS
  • /packages/common/model containing entity model used by all packages. We use here ES classes. This gives us computed properties and rich internal logic. It helps avoid common antipattern: Anemic Domain Model, as discussed by Martin Fowler. Front-end and back-end now use the same rich entity model. It brings code reuse, consistent behaviour and less bugs.
  • /packages/common/database, intended for use in back-end packages. It provides access to hypothetical database store. Here we simply use a bunch of JSON files with sample data.
  • /packages/common/ui, intended for use in front-end packages, contains shared services such as APIClient, used totalk to back-end in consistent way. APIClient also maps raw JSON to entity classes. Here we simply use window.fetch. In real project I’d go for something like Axios. This allows using the same API client in the back-end code.

Back-end packages

We use Fastify framework to build the API back-end. It’s a personal preference - you could use any other framework in a similar fashion.

API packages define endpoints relevant to module’s business purpose. For example /customers/api package exposes:

  • GET /customer endpoint for fetching all customers
  • GET /customer/:id endpoint for fetching a specific customer.

The package does not run these endpoints. It only exports API routes. It defines request schemas following OpenAPI specification, and implements route handlers.

Fastify framework validates incoming requests using these schemas. See for example packages/applets/inventory/api/src/routes/product/get-one/schema.js:

    export const schema = {
      description: 'Returns a product',
      tags: ['Inventory'],

      params: {
        type: 'object',
        properties: {
          id: {
            type: 'number',
            description: 'Product identifier'
          }
        }
      }
    }

Server application

Server application is found in /application/server package. It imports routes from all functional modules and combines them into one back-end. You can see it in /packages/application/server/src/routes/index.js:

    import statusRoutes from './status'
    import customerRoutes from '@vue-vite-monorepo/customers.api'
    import inventoryRoutes from '@vue-vite-monorepo/inventory.api'

    const routes = [
      ...statusRoutes,
      ...customerRoutes,
      ...inventoryRoutes
    ]

    export default function (app) {
      for (const route of routes) {
        app.route(route)
      }
    }

Build generates UMD bundle containing the entire server application. You can run it with NodeJS. Before that, you need to install all required third-party dependencies such as fastify.

Fastify uses fastify-swagger plugin to generate API documentation and test pages. The back-end serves API documentation at http://localhost:3333/documentation:

Back-End Documentation

Front-end packages

Front-end packages contain Vue components, views and navigation routes leading to these views. For example /customers/ui package contains view customers.vue. This view fetches customers from API endpoint GET /customer and displays them in a list. You can access this view using /customers route. The route is declared in /packages/applets/customers/ui/src/routes/index.js:

    import Customers from '../views/customers.vue'

    export default [
      {
        path: '/customers',
        name: 'customers',
        component: Customers,
      }
    ]

Front-end packages do not run their views. They only export UI routes associated with UI views. Build process combines all views and routes into client application.

Client application

Client application is found in /application/client package. It imports UI routes from all functional modules and combines them into front-end application, as seen in /packages/application/client/src/routes/index.js:

    import Home from '../views/home.vue'
    import customerRoutes from '@vue-vite-monorepo/customers.ui'
    import inventoryRoutes from '@vue-vite-monorepo/inventory.ui'

    const routes = [
      {
        path: '/',
        name: 'home',
        component: Home
      },
      ...customerRoutes,
      ...inventoryRoutes
    ]

    export default createRouter({
      history: createWebHistory(),
      routes
    })

Build generates application bundle, which you can host on any web server.

Build configurations

Vite requires build configuration file vite.config.js. It contains instructions how to build a package. This becomes a daunting task, as project grows in size. In large projects you might end up with tens and hundreds of build configuration files. Changes to build process can become difficult and time consuming.

Truth is, all these configuration files are quite similar if not identical. There is no need to duplicate them. Instead, we’ve defined a set of typical configurations. Packages can now use them to populate their own configuration files. Now creating vite.config.js file for another package becomes simple. For example, configuration for Customers API package, found in packages/applets/inventory/api/vite.config.js looks like this:

import { getServerConfiguration } from '../../../../configuration'
export default getServerConfiguration()

We say here: Hey, we build here a package for server-side. Get us a default configuration for this type of package and we’re good..

You can customize default configuration, by passing options, as described in Vite documentation. For example, to switch off minification, do this:

    import { getServerConfiguration } from '../../../../configuration'
    export default getServerConfiguration({
      build: {
        minify: false
      }
    })

We have the following default configurations in /configuration store:

  • vite.common.js - configuration for common (isomorphic) packages, obtained with getCommonConfiguration() function
  • vite.server.js - configuration for server-side packages, obtained with getServerConfiguration() function
  • vite.client.js - configuration for client-side packages, obtained with getClientConfiguration() function
  • vite.application.js - configuration for front-end Vue applications, obtained with getApplicationConfiguration() function

Default configuration files are actually very simple. Vite comes ready with reasonable defaults. There is little you need to add, to start building packages. For example, configuration file for Vue packages looks like this:

    import vue from '@vitejs/plugin-vue'

    export default {
      plugins: [
        vue()
      ],

      build: {
        lib: {
          entry: 'src/index.js'
        },
        minify: 'eslint'
      }
    }

All it does is tell Vite to:

  • build the package into a library, starting with src/index.js
  • use vue plugin to compile .vue single-file components
  • use eslint to minify output, only because I prefer it

Easy as pie!

Summary

I did my best to keep this blueprint simple and pragmatic. I’ve omitted some details, which you will need in a real project. It is a starting point, and you can improve and extend in many ways.

It might all seem a bit complex to less experienced developers. But I hope that readers will have a close look and get some good ideas from it. This approach has helped in many large projects, which I have worked on. As project grows in complexity and size, benefits of this architecture become evident. It helps me find way even in very large codebases, like the one I’m currently developing, with ca. 300,000 lines of code, and more to come.

I’m very happy to see that Vite fits well within this architecture. I have no doubts that I can use it as replacement for webpack, rollup or other build tools. Vite is blazing fast and easy to configure. I hope it becomes a helpful addition to your toolbelt! Let me know when your team needs help or advice with that!

References

LICENSE

Copyright 2021, Tomasz Waraksa

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.