@tactical-ddd/vue

811 3
Install
npm install @tactical-ddd/vue
Latest Version:0.3.1
Vue:3
License:MIT
Last Updated:Sep 23, 2026
Links: GitHub  ·  npm
Maintainer: kayun.artem

npm version npm downloads license CI codecov

@tactical-ddd/vue

@tactical-ddd/vue is the Vue layer of the @tactical-ddd ecosystem — a collection of shared, domain-agnostic Vue 3 building blocks (composables, components, and utilities) for applications structured around Domain-Driven Design (DDD) and Clean Architecture.

It depends on vue alone, so the same package serves any Vue 3 runtime — a Vite SPA, Nuxt, or a custom renderer.

Philosophy

In a DDD / Clean Architecture workspace, business logic lives inside isolated domains and the framework details stay at the edges. This package is the shared Vue kernel that sits beneath those domains:

  • It holds only generic, reusable Vue primitives — never business logic tied to a specific domain.
  • Domains and feature layers (libs/[domain]/features) may import from it; it must never import back from any domain.
  • Everything here is framework-presentation glue (composables, wrappers, components), keeping the domain core layers pure and free of Vue.

The suite is being built out incrementally. Utilities currently available:

  • useObserved — exposes a domain stream to a component as a ref that tracks its latest value.
  • useWatch — the same for a facade's watch, collapsing "not subscribed yet" into loading.

More components, composables, and utilities are planned. This document covers what ships today.

Installation

npm install @tactical-ddd/vue
# peer dependencies
npm install vue @tactical-ddd/core

@tactical-ddd/core is needed for the stream contract useObserved accepts; it is imported as a type only, so nothing from it ends up in the bundle.

Requires Vue >= 3.3 (for toValue / MaybeRefOrGetter, which the reactive source argument is built on).

Utilities

useObserved

A domain publishes changing data as a stream — a facade's watch, an XState actor, an observable. A component needs the current value of that stream and an update whenever it changes, without owning the subscription lifecycle or reaching for a stream library of its own.

useObserved is that bridge: it subscribes inside the active effect scope, exposes the latest value as a shallowRef, and unsubscribes when the scope ends.

useObserved<T>(
  source: MaybeRefOrGetter<Subscribable<T>>,
): Readonly<ShallowRef<T | undefined>>;
<script setup lang="ts">
import { useObserved } from '@tactical-ddd/vue';

const props = defineProps<{ facade: BeneficiariesFacade }>();

// A getter, not a plain call: the stream is rebuilt — and resubscribed — when
// the argument it depends on changes.
const beneficiaries = useObserved(() => props.facade.observeAll());
</script>

<template>
  <Spinner v-if="beneficiaries === undefined" />
  <List v-else :items="beneficiaries" />
</template>

Worth knowing:

  • undefined means "nothing has arrived yet" — which is not the same as an empty array. A stream that replays its current state to new subscribers (as a watch does) will usually resolve this before the first render.
  • The source may be reactive. Pass a getter or a ref when the stream depends on changing arguments (a route param, a selected id); the composable resubscribes on its own. Pass the stream directly when it never changes.
  • Changing the source forgets the previous value, synchronously. Otherwise a screen would go on showing data belonging to the argument it has moved on from, which reads as a wrong answer rather than as a pending one.
  • Call it in setup. The subscription is bound to the active effect scope and released with it. Called outside a scope it still works, but nothing will unsubscribe for you.
  • Failures belong to the source, not to the composable. It subscribes with a value callback only: an error delivered to the observer would end the subscription for good and the component would silently stop updating. Model failure as part of the value (see Loadable) or catch it where the stream is built.
  • Any Subscribable works — an rxjs Observable, an XState actor, a hand-rolled emitter — because the contract is structural and this package depends on none of them.

useWatch

A facade publishes changing data as a watch — a stream of Loadable states. Consuming it with useObserved leaves a template checking undefined as well as status, and that undefined is not a state of the data: it means the subscription has not started yet.

Since that says nothing beyond what LoadStatus.Loading already says, useWatch collapses the two and never yields undefined:

useWatch<T>(source: MaybeRefOrGetter<Watch<T>>): ComputedRef<Loadable<T>>;
<script setup lang="ts">
import { LoadStatus } from '@tactical-ddd/core';
import { useWatch } from '@tactical-ddd/vue';

const props = defineProps<{ facade: BeneficiariesFacade }>();

const beneficiaries = useWatch(() => props.facade.observeAll());
</script>

<template>
  <Spinner v-if="beneficiaries.status === LoadStatus.Loading" />
  <List
    v-else-if="beneficiaries.status === LoadStatus.Ready"
    :items="beneficiaries.value"
    :refreshing="beneficiaries.stale"
  />
  <!-- Old data beats an empty screen. -->
  <List
    v-else-if="beneficiaries.value !== undefined"
    :items="beneficiaries.value"
    :error="beneficiaries.reason"
  />
  <Failure v-else :reason="beneficiaries.reason" />
</template>

Worth knowing:

  • The name refers to the facade's watch, not to Vue's watch. Nothing here is a reactive effect the caller has to stop.
  • One thing to check, not two. A template branches on status alone, so the loading case cannot be forgotten and value cannot be read in a state that has none.
  • The loading state is a shared constant. A fresh object per evaluation would break reference equality for anything memoising on it, so the same { status: LoadStatus.Loading } is returned every time — including after the source changes.
  • Changing the source returns to loading, synchronously, because the previous state described data the caller has moved on from.
  • Use useObserved for streams that are not watches — an XState actor, a notification, anything not carrying a Loadable. There undefined is meaningful and should stay visible.

Wiring up providers

There is deliberately no composeProviders helper here, unlike in @tactical-ddd/react. React needs one because a context is only reachable through a component, so cross-cutting concerns pile up into a nesting pyramid. Vue has no such constraint:

  • app-wide values go in app.provide(key, value) — no wrapper component at all;
  • libraries ship plugins (app.use(router), app.use(pinia), app.use(i18n)), which is already a flat list;
  • a concern that really is a component (a UI kit's config provider, a per-domain DI container over a route subtree) is usually one or two levels of nesting, which reads fine in a template.

Running unit tests

Run nx test @tactical-ddd/vue to execute the unit tests via Vitest.

Related Packages

4layers

Architecture layer checker for Vue.js / TypeScript apps. Enforces Presentation -...

417 1
@xeno-js/cli

Official Xeno.JS CLI for scaffolding and generating explicit TypeScript applicat...

2,267 1
@xeno-js/vue

Application architecture for Vue with explicit dependency injection, CQRS, compo...

1,952 1
@vt7/vue2-composables

Vue2 composables

592 1
@shopware/composables

Shopware Frontends composables for Vue

141,216 230

Version History

Version Released Vue License
0.3.1 ^3.3.0 MIT
0.3.0 ^3.3.0 MIT
0.2.1 ^3.3.0 MIT
0.2.0 ^3.3.0 MIT
0.0.1