Skip to content

Dependency Container

@vue-modeler/di is a dependency container based on shared composable.

The container solves the problem of managing model and service lifecycle:

  • Simplifies sharing models and services across components
  • Separates business logic from presentation
  • Enables MVVM, DDD, SOLID principles
  • Can be used as a service locator via dc.resolve() outside setup

Main features

  • Lazy loading: creates dependencies only when needed
  • 🗑️ Auto cleanup: removes unused dependencies
  • 🔧 Destructor support: calls destructor on cleanup
  • 💾 Persistent instances: for long-lived services
  • 🧭 Service locator: resolve dependencies at runtime through the container

TIP

The dependency container stores dependencies but does NOT support autowire. You wire dependencies in your own module or layer.

How it works

The container follows "create on demand, remove when unused":

  1. Register factory — you register a factory for the instance and get a shared composable
  2. Create instance — the instance is created only on first access
  3. Reuse — subsequent access returns the same instance
  4. Reference tracking — the container counts how many components use the instance
  5. Cleanup — when the count reaches 0, the instance is removed

Registering a factory

provider registers a dependency factory and creates a shared composable for use in components.

The factory is a simple function that can return any synchronous value, except null, undefined, or a Promise (see Factories must be synchronous).

The container stores whatever the factory returns. It does nothing else and does not inject dependencies.

typescript
import { provider } from '@vue-modeler/di';

const useDependency = provider(() => {
  // your instance factory
  return {
    // instance with methods and data
  };
});


// this works too
const useSymbol = provider(() => new Symbol('dependency'));
const useNumber = provider(() => 10);
const useTrue = provider(() => true);

// pass dependencies into the constructor
const useSomeModel = provider(() => new SomeModel(
  useDependency(),
  useSymbol(),
  useNumber(),
  useTrue()
));

Using in components

Example of using a provider in a component template:

html
<template>
  <div>{{ model.state }}</div>
</template>

<script setup lang="ts">
import { useDependency } from '@/providers/myDependency';

const model = useDependency(); // get the instance
</script>

Persistent instances

Sometimes you need an instance that stays in memory after use, e.g. app-level services, caches, or state managers.

Pass persistentInstance: true to provider:

typescript
const usePersistentService = provider(
  () => new MyService(),
  { persistentInstance: true }
);

Persistent instances:

  • Remain in the container even after the scope is released
  • Keep their state across component remounts
  • Nested providers inside a persistent provider become persistent automatically
  • Useful for app-level services, caches, and state managers

Example with nested providers:

typescript
// nested provider becomes persistent with the parent
const useNestedService = provider(() => new NestedService());

const usePersistentService = provider(
  () => new MainService(useNestedService()),
  { persistentInstance: true }
);

WARNING

On the client, use persistent instances with care — they are not removed automatically.

For SSR, persistent instances are safe: each request gets a new container instance, and the previous one is discarded with its contents.

Accessing the container in a factory

The factory receives an object whose dc property is the active container. Use it when an instance needs to resolve other dependencies at runtime (outside setup):

typescript
import { provider, type DependencyContainer } from '@vue-modeler/di';

const useApi = provider(({ dc }) => new ApiClient(dc));

dc is the public DependencyContainer. Nested providers called synchronously inside the factory inherit the same container automatically.

Resolving outside setup

useDependency() may only be called synchronously in a component setup or inside another provider factory. For runtime code — event handlers, router hooks, watch, code after await, tests, or SSR — resolve through a container reference instead:

typescript
const model = dc.resolve(useDependency);

resolve() returns (or creates) the instance in that container. Unlike useDependency() in setup, it does not bind the instance to a Vue scope, so the instance lives as long as the container.

Redefining a factory

Each provider exposes redefine(factory) to swap the factory before the first resolve — useful for tests and SSR mocks. The replacement receives the same { dc } argument plus prevFactory, so you can wrap the previous implementation:

typescript
const useService = provider(({ dc }) => new RealService(dc));

useService.redefine(({ dc, prevFactory }) => {
  const previous = prevFactory?.({ dc });
  return new MockService(previous);
});

Redefining after an instance already exists in the target container throws Provider was redefined after instance creation. Resolve mocks from a dedicated container to keep them isolated.

Factories must be synchronous

A factory must run to completion synchronously and return the instance directly — never a Promise. async factories, await in the factory body, or returning a Promise are not supported and throw on registration. Do async work on the created instance, not in the factory.

typescript
// Not allowed
const useModel = provider(async ({ dc }) => new MyModel(dc));

// OK — async work lives on the instance
const useModel = provider(({ dc }) => new MyModel(dc));

Released under the MIT License.