@tactical-ddd/vue
| Install | |
|---|---|
npm install @tactical-ddd/vue |
|
| Latest Version: | 0.3.1 |
| Vue: | 3 |
| License: | MIT |
| Last Updated: | Sep 23, 2026 |
| Links: | GitHub · npm |
@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
corelayers 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" intoloading.
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:
undefinedmeans "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
Subscribableworks — an rxjsObservable, 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
statusalone, so the loading case cannot be forgotten andvaluecannot 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
useObservedfor streams that are not watches — an XState actor, a notification, anything not carrying aLoadable. Thereundefinedis 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
Official Xeno.JS CLI for scaffolding and generating explicit TypeScript applicat...
Application architecture for Vue with explicit dependency injection, CQRS, compo...
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 |