Leveraging Dependency Injection for more modular Vue 3 applications

For certain applications it is sometimes convenient to leverage an inversion of control / a dependency injection mechanism.

I ran into this problem while designing the architecture of a web 3 decentralized application that must be available on several blockchains, the question quick appeared: “How do I limit code duplication and maintain a good architecture in my project?”

Here’s how I answered this question.

What we are going to build

To keep things as simple as possible, we are only going to build two applications that displays “Hello” or “World” on the browser, with a single code base for the whole app and the dependency injection happening at the top-level.

How will this work?

The main codebase for the application will use an instance of a class (consider it a service) which provides a message to be displayed.

Rather than having this class defined in the application code itself, it will be defined in a library as an interface (with no implementation). The app will then use an injected instance of a class that implements that interface.

So we will have the MessageProvider interface with 2 implementations: HelloProvider and WorldProvider.

Structure of the project

The project consists in a few libraries and two apps that just rely on the libraries. Two manage this structure, I will use Yarn as a package manager and its workspaces features to group everything into a single monorepo.

⚠️ Do not forget to install the dependencies when you have defined all the package.json files for all the libraries and apps.

Standard yarn workspaces structure then: libraries grouped in a packages directory and apps in an apps directory.

| package.json
\- packages
  \- core
  \- hello-provider
  \- world-provider
  \- app-generator
\- apps
  \- hello-app
  \- world-app

The two apps and the app-generator library are Vite Vue 3 + TypeScript applications generated with this command line ran into the apps directory.

yarn create vite application-name --template vue-ts

The core, hello-provider and world-provider libraries are simple TypeScript libraries with a package.json exposing a main property that indicates where to look for the code. No transpilation needed here since it is only a local library and will not be published to npm.

The root package.json library is the following so that yarn detects the correct workspaces

// file: package.json
{
  "private": true,
  "workspaces": [
    "packages/*",
    "apps/*"
  ]
}

The core library

This is the library responsible for defining the interface that the app uses.

Here is the package.json file for this library:

// file: packages/core/package.json
{
  "name": "core",
  "version": "1.0.0",
  "main": "src/index.ts"
}

As you can see, this library references its src/index.ts file as the entry point.

This file contains the following:

// file: packages/core/src/index.ts
export interface MessageProvider {
  getMessage: () => string;
}

The hello-provider and world-provider libraries

These libraries directly depend on the core library because they are responsible for providing implementations of the MessageProvider interface.

For the sake of simplicity, I will only show what’s inside the hello-provider package since only the name and the returned string differs with the world-provider package.

// file: packages/hello-provider/package.json
{
  "name": "hello-provider",
  "version": "1.0.0",
  "main": "src/index.ts",
  "dependencies": {
    "core": "*"
  }
}
// file: packages/hello-provider/src/index.ts
export { default as HelloProvider } from "./HelloProvider";
// file: packages/hello-provider/src/HelloProvider.ts
import { MessageProvider } from "core";

export default class HelloProvider implements MessageProvider {
  getMessage(): string {
    return "Hello"; // "World" in the world-provider package 
  }
}

As you can see, this is pretty straightforward.

Creating the applications

Implementation of the app-generator package

The app-generator package is the actual app, the apps defined in the apps directory only serve the purpose of providing the right implementations of the MessageProvider interface to this app.

The package.json is very standard and you can see that it only requires the core library as a dependency (in addition to what vite brings by default)

// file: packages/app-generator/package.json
{
  "name": "app-generator",
  "version": "1.0.0",
  "main": "src/main.ts",
  "dependencies": {
    "core": "*",
    "vue": "^3.2.25"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^2.0.0",
    "typescript": "^4.4.4",
    "vite": "^2.7.2",
    "vue-tsc": "^0.29.8"
  }
}

You can also see that since this application is actually considered a package, the entry point is defined as the src/main.ts file.

// file: packages/app-generator/src/main.ts
import App from "./App.vue";

export const MessageProviderSymbol = Symbol("MESSAGE_PROVIDER");

export { App as CoreApplication };

A few things are going on in this file: first, we import the App from the dedicated App.vue file. Then we define and export a MessageProviderSymbol which will later be required for providers to define what implementation of the MessageProvider they are passing to the app. Then, the application itself is being exported as CoreApplication, the component that will be used by the apps in the apps directory.

Finally, let’s see the actual code of the application

// file: packages/app-generator/src/App.vue
<script setup lang="ts">
import { inject, ref } from "vue";
import { MessageProvider } from "core";
import { MessageProviderSymbol } from "./main";

const messageProvider = inject<MessageProvider>(MessageProviderSymbol);

const message = ref(messageProvider?.getMessage() ?? "Missing MessageProvider implementation.");
</script>

<template>
  <h1>{{ message }}</h1>
</template>

Now you should understand how the whole injection of dependency is used by the application: the CoreApplication component (i.e. the actual application) must be wrapped inside a component that provides an implementation of the MessageProvider class, which is then injected in the app and called to get the message to display.

Implementation of the hello-app and world-app packages

Now the applications themselves are only responsible for providing the right implementations of the MessageProvider interface. So I will only show how it is implemented in the hello-app application. At this point you should be able to easily guess how the world-app is implemented.

// file: apps/hello-app/package.json
{
  "name": "hello-app",
  "version": "0.0.0",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc --noEmit && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "vue": "^3.2.25",
    "app-generator": "*",
    "hello-provider": "*"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^2.0.0",
    "typescript": "^4.4.4",
    "vite": "^2.7.2",
    "vue-tsc": "^0.29.8"
  }
}

The app is actually mounted in the src/main.ts file:

// file: apps/hello-app/src/main.ts
import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

And finally, the App.vue file, which actually provides the HelloProvider, implementation of the MessageProvider interface:

// file: apps/hello-app/src/App.vue
<script setup lang="ts">
import { provide } from "vue";
import { CoreApplication, MessageProviderSymbol } from "app-generator";
import { HelloProvider } from "hello-provider";

provide(MessageProviderSymbol, new HelloProvider());
</script>

<template>
  <CoreApplication />
</template>

As you can see, we provide an instance of HelloProvider through the provide function, instance that is then used by the CoreApplication.

Try to implement the world-app application, this should be really easy now!

Test that everything worked

Once you have installed all the dependencies (with the yarn command), you can test if everything works.

Open a browser window at http://localhost:3000, run the yarn workspace hello-app dev command and verify that it displays “Hello” in the browser window. Now stop the command with Ctrl+C and run yarn workspace world-app dev to check if the text in the browser window has been updated to “World”.

Conclusion

Dependency Injection in Vue is possible with provide/inject mechanism but it could also be leveraged via simple props if only the top-level application component must use the provided object/instance.

Thanks for reading!