Skip to content

CQRS Composables & Handlers: Bridging UI and Business Logic

CQRS Composables & Handlers: Bridging UI and Business Logic

Section titled “CQRS Composables & Handlers: Bridging UI and Business Logic”

In standard Vue.js applications, components are frequently overloaded with disparate responsibilities: managing UI state (loaders, error messages), executing HTTP requests, handling business logic, and orchestrating component lifecycle events. This widespread anti-pattern results in monolithic, untestable .vue files that are extremely difficult to maintain or refactor.

Xeno Vue enforces a strict Separation of Concerns by dividing these responsibilities across two distinct architectural primitives: Vue Composables (Presentation Layer) and CQRS Handlers (Application Layer).


To protect the core business domain from UI framework churn, Xeno establishes a firm boundary between reactivity and execution:

  1. The Composable (use-*.ts): Acts as the reactive ViewModel. It manages Vue-specific state (ref for loading, error, and data), handles component lifecycle hooks (onUnmounted), and orchestrates safe execution via the ClientMediator.
  2. The Handler (*.handler.ts): Acts as the pure Application logic. It is a completely framework-agnostic TypeScript class that receives an isolated payload (Command or Query) and an AbortSignal, executes the required operations (like invoking a Remote Data Source), and returns a deterministic ResultType<T> monad.

By splitting these layers, you ensure that your business logic can be unit-tested in pure Node.js environments without ever mounting a DOM or mocking Vue reactivity systems.


Xeno’s CLI generates highly optimized Vue Composables designed to safely bridge the gap between user interactions and the IoC container. A standard Xeno Composable manages four critical areas:

The composable encapsulates the localized state required by the UI, such as loading flags, error strings, and the resulting data payload. This prevents global state pollution (e.g., using Pinia for ephemeral request states) and keeps the component template clean.

Instead of importing API clients directly, the composable uses ServicesUtils.useApp() to resolve both the ClientMediator and the specific CQRS Handler required for the operation directly from the frontend Dependency Injection container.

3. Cooperative Cancellation (AbortController)

Section titled “3. Cooperative Cancellation (AbortController)”

Network requests in SPAs often lead to memory leaks or state corruption if a user navigates away from a page before a slow API call completes. To prevent this, the composable automatically instantiates an AbortController before executing the mediator pipeline. It exposes an abort() function to the UI for manual cancellation and automatically binds it to Vue’s onUnmounted lifecycle hook, guaranteeing that pending requests are severed when the component is destroyed.

The composable evaluates the Result monad returned by the mediator. If the operation fails, it extracts the error message and exposes it to the UI reactively; if it succeeds, it updates the data ref safely.


Below is an example of a Command and its matching Composable, exactly as generated by the Xeno CLI engine.

The handler is an isolated class resolved by the DI container. It accepts the AbortSignal and performs the actual remote mutation. Notice the complete absence of Vue imports (ref, reactive, etc.).

src/features/users/create-user.handler.ts
import type { ResultType } from '@xeno-js/vue';
import { Result } from '@xeno-js/vue';
import type { CreateUserCommand } from './create-user.command';
import type { CreateUserResponse } from './create-user.model';
export class CreateUserHandler {
constructor(
// Injected Remote Data Sources from the IoC Container
) {}
public async handle(command: CreateUserCommand, signal?: AbortSignal): Promise<ResultType<CreateUserResponse>> {
// Implement your frontend business logic or API calls here.
// Example: return await this.dataSource.post('/api/users', command.payload, { signal });
return Result.ok();
}
}

The composable acts as the reactive proxy. It connects the Vue component to the ClientMediator and handles the AbortController lifecycle.

src/features/users/use-create-user.composable.ts
import { ref, onUnmounted } from 'vue';
import { Result } from '@xeno-js/vue';
import { ServicesUtils } from '@/use-app';
import { CreateUserCommand } from './create-user.command';
import type { CreateUserRequest, CreateUserResponse } from './create-user.model';
export function useCreateUser() {
const loading = ref(false);
const error = ref<string | null>(null);
let abortController: AbortController | null = null;
const execute = async (payload: CreateUserRequest): Promise<Result<CreateUserResponse>> => {
// Prevent overlapping executions
if (loading.value) return Result.fail(new Error('Already executing'));
abortController = new AbortController();
loading.value = true;
error.value = null;
try {
// Resolve the Mediator and Handler from the Xeno IoC Container
const { mediator, CREATE_USER_HANDLER: handler } = ServicesUtils.useApp();
const command = new CreateUserCommand(payload);
// Dispatch through the Mediator, passing the cancellation signal
const result = await mediator.send(command, async () => {
return await handler.handle(command, abortController!.signal);
});
// Unwrap the result and bind errors to the reactive UI state
if (!result.isOk()) {
error.value = result.getErrorOrThrow().message;
}
return result;
} finally {
loading.value = false;
abortController = null;
}
};
// Expose manual cancellation
const abort = () => {
if (abortController) {
abortController.abort();
}
};
// Automatically cancel pending operations when the component unmounts
onUnmounted(() => {
abort();
});
return { loading, error, execute, abort };
}

With the separation established, your Vue component (.vue) becomes incredibly thin and declarative. It only needs to invoke the composable and bind the reactive variables to the template:

<script setup lang="ts">
import { useCreateUser } from '@/features/users/use-create-user.composable';
const { loading, error, execute, abort } = useCreateUser();
const submitForm = async () => {
const result = await execute({ name: 'John Doe', email: 'john@example.com' });
if (result.isOk()) {
// Navigate or show success toast
}
};
</script>
<template>
<form @submit.prevent="submitForm">
<!-- UI bindings are clean and direct -->
<div v-if="error" class="error-banner">{{ error }}</div>
<button type="submit" :disabled="loading">
{{ loading ? 'Saving...' : 'Create User' }}
</button>
<button type="button" @click="abort" v-if="loading">
Cancel
</button>
</form>
</template>

By adhering to this pattern, you eliminate technical debt from the presentation layer, enforce strict memory management through cooperative cancellation, and ensure that your core business capabilities remain perfectly isolated.


Xeno is an MIT-licensed open source project. It can grow thanks to the support of these awesome people. If you’d like to join them, please read more at support section