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!