Back

Implementing the Dependency Injection pattern in Vue 3

January 21, 2025

Dependency Injection is a design pattern where objects or modules do not create their own dependencies. Instead, dependencies are “injected” from the outside, often through a constructor or function parameters.

// This function creates its `database` dependency
function getUserById (id: number) {
    const db = new Database(/* config */)
    return db.getUserById(id)
}

// This function's `database` dependency is injected
function getUserById (database: Database, id: number) {
    return db.getUserById(id)
}

Thanks to dependency injection, functions or classes can a dependenciy without knowing how it's instantiated or configured. In this guide, we will see how to implement this pattern with Vue 3. We will explain how this helps decouple logic to improve code maintainability and testability.

The IoC container

Using the Vue instance

In the context of Dependency Injection, IoC stands for Inversion of Control. The IoC container centralizes the responsibility of creating and configuring dependencies. Centralization makes it easier to swap services implementation for tests or migrations.

While it’s common for applications the pattern to create a Container class representing the IoC container, this approach might not be necessary with Vue. Indeed, Vue comes with the dedicated Provide / Inject API.

This allows us to directly bind dependencies to the Vue instance, effectively using the Vue app as an IoC container. To make them accessible in the entire app, we will use a Vue Plugin.

Writing the dependency plugin

To bind our dependency to the Vue app, we’ll leverage app-level provide. This allows us to make the DatabaseService instance available to all components and composables in the application.

src/plugins/database.ts:

import { App } from 'vue';
import { databaseInjectionKey } from '@/keys';
import { DatabaseService } from '@/services/DatabaseService';

export default {
  install(app: App) {
    const database = new DatabaseService(/* some config */)
    app.provide(databaseInjectionKey, database);
  },
};

In this example, we use a databaseInjectionKey imported from @/keys to ensure the provided dependency is fully typed. See how to type provide/inject for more details.

src/keys.ts:

import type { DatabaseService } from '@/services/DatabaseService';
import { InjectionKey } from 'vue';

export const databaseInjectionKey = Symbol() as InjectionKey<DatabaseService>;

With our Plugin defined, let’s register it.

Registering the Plugin

We will register our Plugin when creating the Vue app.

src/main.ts:

import { createApp } from 'vue';
import dbPlugin from '@/plugins/database';

const app = createApp({});

app.use(dbPlugin);

After registration, the DatabaseService instance accessible throughout the entire application. Components and composables can use it without knowing how it's created.

Dependency injection in Vue composables

Using dependencies (in a composable)

Let's use our injected database dependency in one of our composable. Here’s how we can define a composable that uses the DatabaseService to get data about users:

src/composables/useUsers.ts:

import { databaseInjectionkey } from "@/constants";
import { inject } from 'vue';

export function useUsers() {
  const db = inject(databaseInjectionKey);

  if (!db) {
    throw new Error('No database bound to the container');
  }

  const list = async () => {
    return database.users.findAll();
  };

  const getById = async (id: number) => {
    return database.users.find(id);
  };

  return {
    list,
    getById,
  };
}

It uses the DatabaseService without caring about about how it’s instantiated. This is particularly useful when working with shared logic like accessing the database.

For brevity, we directly pulled the container from the Vue app. In practice, I recommend wrapping dependencies in a composable for better type safety and consistent error handling.

Using the composable in a component

To illustrate how this composable is used, let’s create a UserList component that displays a list of users.

src/components/UserList.vue:

<script lang="ts" setup>
import { onMounted } from 'vue';
import { useUsers } from '@/composables/useUsers';
const { list } = useUsers();

const   users = await list();
</script>

<template>
  <div v-for="user in users" :key="user.id">
    {{ user }}
  </div>
</template>

This component fetches the user list using the useUsers composable and renders it in a simple loop. The composable abstracts away the implementation details of how users are retrieved.

Writing tests

Sorry, this section is still under construction 😅 Come back soon!

Conclusion

We have implemented the Dependency Injection design pattern in a Vue application. This allows the decoupling of logic in three identified areas:

  • Services creation — in the container
  • Business logic — in the composable
  • Rendering logic — in the component

This decoupled approach promotes reusability and makes testing easier, as different modules can be mocked or replaced without affecting the consuming code.

👋 About the author

I'm Laurent, but people online call me Strift. I'm a freelance engineer specializing in developer experience.

✍️ Related posts

Read my other articles.
Back to the blog