Vue 3 Architecture — Functional Core, Imperative Shell
When to Use This Skill
Use this skill whenever the user asks you to:
- Create a new feature, component, view, or composable
- Add a new API endpoint integration
- Refactor existing code to match the project's architecture
- Wire up a new data flow (API → state → UI)
Architecture Overview
This project follows a functional core, imperative shell pattern with four layers:
┌─────────────────────────────────────────┐
│ Views (src/views/) │ Ultra-thin: imports a component,
│ Route orchestration only │ wraps in <main>, nothing more
├─────────────────────────────────────────┤
│ Smart Components (src/components/) │ Imports composables, wires state
│ Orchestration + lifecycle + handlers │ to templates via event handlers
├─────────────────────────────────────────┤
│ Composables (src/composables/) │ Functional core: ref/reactive/
│ Reactive state, readonly exposure, │ computed with explicit mutation
│ error handling, loading state │ methods. NO raw DOM access.
├─────────────────────────────────────────┤
│ API Client (src/api/) │ Imperative shell: raw HTTP via
│ + AppState (src/AppState.ts) │ openapi-fetch. Type-safe, has
│ Side effects: network, localStorage │ middleware (auth, error, bar-id)
└─────────────────────────────────────────┘
Key Principles
- Readonly exposure: Composables expose state as
readonly(ref)— mutations only happen through explicit method calls, never by direct assignment from consumers. - Smart components orchestrate: Components import composables and
AppState, handleonMounted, wire event handlers to composable methods. They never callBarAssistantClientdirectly. - Views are shells: Views only import a smart component and render it. No data fetching, no composables, no lifecycle hooks.
- API client is imperative shell:
BarAssistantClienthas zero reactive state. It's a static class of pure async functions that do I/O. - AppState is plain class (not Pinia):
AppStateextendsclasswith localStorage persistence, accessed viauseAppState()singleton composable.
Step-by-Step Workflow for New Features
When building a new feature, execute in this exact order:
Step 1: Identify Domain Placement
Determine where the files live:
- API methods →
src/api/BarAssistantClient.ts(add static methods) - Reactive logic →
src/composables/<domain>/use<Feature>.ts(new composable) - Orchestration →
src/components/<Domain>/<Feature>.vue(smart component) - View shell →
src/views/<Feature>View.vue(if it's a new route)
Step 2: TypeScript Types
Types come from the generated API schema (src/api/api.d.ts). Import with:
import type { components } from '@/api/api';
export type SomeType = components['schemas']['SomeType'];
If new types are needed beyond the API schema, define them in the composable file alongside the imported types.
Step 3: API Client (Imperative Shell)
Add static methods to BarAssistantClient. Each method:
- Uses
client.VERB()fromopenapi-fetch(already configured) - Returns raw
datafrom the response - Has zero reactive state
- For POST that creates: extract ID from
Locationheader viaextractIdFromLocationHeader
static async getSomething(query = {}) {
return (await client.GET('/something', { params: { query } })).data;
}
static async saveSomething(body: components['schemas']['SomethingRequest']) {
const { response } = await client.POST('/something', { body, parseAs: 'stream' });
return extractIdFromLocationHeader(response);
}
static async deleteSomething(id: number) {
return (await client.DELETE('/something/{id}', { params: { path: { id } } })).data;
}
Step 4: Composable (Functional Core)
Create src/composables/<domain>/use<Feature>.ts. Every composable follows this template:
import { ref, readonly, computed } from 'vue';
import BarAssistantClient from '@/api/BarAssistantClient';
import type { components } from '@/api/api';
import { useSaltRimToast } from '@/composables/toast';
import { useI18n } from 'vue-i18n';
export type SomeType = components['schemas']['SomeType'];
export function useSomeFeature() {
// Reactive state
const items = ref<SomeType[]>([]);
const isLoading = ref(false);
// Dependencies
const toast = useSaltRimToast();
const { t } = useI18n();
// Mutations (the ONLY way state changes)
async function fetchItems() {
isLoading.value = true;
try {
items.value = (await BarAssistantClient.getSomething())?.data || [];
} finally {
isLoading.value = false;
}
}
async function deleteItem(id: number) {
isLoading.value = true;
try {
await BarAssistantClient.deleteSomething(id);
items.value = items.value.filter(item => item.id !== id);
toast.default(t('domain.delete-success'));
} catch (e: any) {
toast.error(e.message);
} finally {
isLoading.value = false;
}
}
// Derived state
const activeItems = computed(() => items.value.filter(i => i.status === 'active'));
// Return: readonly state + mutation methods
return {
items: readonly(items),
isLoading: readonly(isLoading),
activeItems,
fetchItems,
deleteItem,
};
}
Critical rules for composables:
- Expose state as
readonly(ref)— consumers cannot mutate directly - All mutations happen through named async functions
- Use try/catch with toast for error feedback
- Import
useI18nfor translatable strings viat() - Put composable in a subdirectory matching the domain (e.g.,
composables/bar/useBars.ts) - The only time it is perfectly acceptable to expose a fully mutable ref from a composable is when writing a utility specifically designed to manage ephemeral, isolated form state (e.g., a useForm validation utility).
Step 5: Smart Component (Orchestration)
Create src/components/<Domain>/<Feature>.vue. This is the orchestration layer:
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useSomeFeature } from '@/composables/domain/useSomeFeature';
import { useAppState } from '@/composables/useAppState';
import { useConfirm } from '@/composables/confirm';
import { useSaltRimToast } from '@/composables/toast';
import ChildPresentationalComponent from '@/components/Domain/ChildPresentational.vue';
// Composables
const { t } = useI18n();
const appState = useAppState();
const toast = useSaltRimToast();
const confirm = useConfirm();
const { items, isLoading, fetchItems, deleteItem } = useSomeFeature();
// Local refs
const showDialog = ref(false);
// Lifecycle
onMounted(() => {
fetchItems();
});
// Event handlers — these call composable methods, never BarAssistantClient directly
function handleDelete(id: number) {
confirm.show(t('domain.confirm-delete'), {
onResolved: async (dialog: { close: () => void }) => {
await deleteItem(id);
dialog.close();
},
});
}
</script>
<template>
<PageHeader>{{ t('domain.title') }}</PageHeader>
<div>
<OverlayLoader v-if="isLoading" />
<ChildPresentational
v-for="item in items"
:key="item.id"
:item="item"
@delete="handleDelete(item.id)"
/>
</div>
</template>
Critical rules for smart components:
- Import composables and AppState — never call
BarAssistantClientdirectly - Handle
onMountedto trigger initial data fetch - Wire event handlers to composable methods
- Use
useConfirm()for destructive actions - Use
useSaltRimToast()only via composable methods (already handled inside composable) - Form submission handlers: optimistic updates are OK, but revert on API failure
Step 6: View Shell
Create src/views/<Feature>View.vue. This is always ultra-thin:
<script setup lang="ts">
import FeatureComponent from '@/components/Domain/Feature.vue';
</script>
<template>
<main>
<FeatureComponent />
</main>
</template>
Views must:
- Only import and render one smart component
- Never import composables, AppState, or API client
- Never have
onMountedor lifecycle hooks - Never fetch data
File Naming Conventions
| Layer | Pattern | Example |
|---|---|---|
| API client methods | camelCase verbs | getBars(), saveBar(), deleteBar() |
| Composables | use<Feature>.ts |
useBars.ts, useAuth.ts |
| Smart components | PascalCase .vue |
BarIndex.vue, CocktailRecipeCard.vue |
| Views | PascalCase + View suffix |
BarsView.vue, CocktailsFormView.vue |