MVVM in Practice — A Feature from Four Layers

The theory article covered why the layers exist. This one shows them working. We'll trace a single user action — clicking "Continue" on the investment amount form — through all four layers from the Vue template to the network and back. The mental model works for any feature, not just this one.
Layer 1 — The Service
ApiClient is a plain TypeScript class. It knows one thing: how to make HTTP requests.
// data/service/types.ts
export interface ApiResponse<T> {
data: T | undefined;
status: number;
headers: Headers;
}
// data/service/apiClient.ts
export class ApiClient {
constructor(private baseURL: string) {}
put<T>(url: string, data?: unknown): Promise<ApiResponse<T>> {
return this.request<T>(url, { method: 'PUT', body: ApiClient.toBody(data) });
}
}No Vue imports. No store references. No route awareness. It takes a URL and a payload, sends the request, and returns a typed envelope. That's the entire contract.
The service doesn't care what you do with the response. That's the repository's job.
Layer 2 — The Repository
useRepositoryInvestment is a Pinia store. Its job is to wrap API calls in reactive state so the rest of the app can observe what's happening without caring about the mechanics.
The pattern that makes this work is ActionState<T>:
// data/repository/repository.ts
export type ActionState<T> = {
data: T | undefined;
loading: boolean;
error: Error | null;
};Every async operation in every repository runs through withActionState, which manages the lifecycle automatically:
export async function withActionState<T>(
stateRef: Ref<ActionState<T | undefined>>,
action: () => Promise<T>,
): Promise<T> {
stateRef.value = { loading: true, error: null, data: undefined };
try {
const result = await action();
stateRef.value = { loading: false, error: null, data: result };
return result;
} catch (err) {
stateRef.value = { loading: false, error: err as Error, data: undefined };
throw err;
}
}The setAmount action is one line of actual logic:
// data/investment/investment.repository.ts
const setAmount = async (slug: string, id: string, profileId: string, data: object) =>
withActionState(setAmountState, async () => {
const response = await apiClient.put(`/auth/invest/${slug}/amount/${id}/${profileId}`, data);
return response.data as { number_of_shares: number };
});The repository's contract: it sets state.error and rethrows. It never navigates. It never shows a toast. It never decides what the UI should do next. Whoever called it makes those decisions.
The repository knows nothing about the form or the page. The ViewModel is where data from multiple repositories meets user intent.
Layer 3 — The ViewModel
What it assembles
useInvestAmount opens three repositories and a profiles domain store:
// features/investProcess/logic/useInvestAmount.ts
const investmentRepository = useRepositoryInvestment();
const walletRepository = useRepositoryWallet();
const evmRepository = useRepositoryEvm();
const profilesStore = useProfilesStore();Four separate data sources. The ViewModel's job is to merge them into something the View can use directly.
The clearest example is fundingPayload — a computed() that collapses four different funding-type branches into a single object:
const fundingPayload = computed(() => {
const fundingType = fundingFormRef.value?.model.funding_type;
const componentData = fundingFormRef.value?.componentData;
if (!fundingType) return undefined;
if (fundingType === FundingTypes.ach) {
if (componentData?.isInvalid) return undefined;
return {
funding_type: FundingTypes.ach,
payment_data: {
account_number: componentData.accountNumber,
routing_number: componentData.routingNumber,
account_holder_name: componentData.accountHolderName,
account_type: componentData.accountType,
},
};
}
if (fundingType === FundingTypes.cryptoWallet) {
return {
funding_type: FundingTypes.cryptoWallet,
payment_data: {
wallet: getEvmWalletState.value.data?.address || '',
},
};
}
if (fundingType !== FundingTypes.wallet && fundingType !== FundingTypes.wire) {
return {
funding_source_id: Number(fundingType),
funding_type: FundingTypes.wallet,
};
}
return { funding_type: fundingType };
});Neither the View nor any single repository could produce this value. The View doesn't know what funding types exist. The repositories don't know about each other. The ViewModel is the only layer that can see both.
Tracing the button click
handleContinue is what runs when the user presses "Continue":
const handleContinue = async () => {
const isValid = validateAllForms();
if (!isValid) return;
const { slug, id, profileId } = route.params;
const dataToSend = buildAmountPayload();
try {
await investmentRepository.setAmount(
slug as string,
id as string,
profileId as string,
dataToSend,
);
} catch (e) {
reportError(e, 'Failed to save investment step');
return;
}
if (setAmountState.value.error) return;
router.push({
name: ROUTE_INVEST_SIGNATURE,
params: { ...route.params, profileId: String(dataToSend.profile_id) },
});
};Step by step:
- Validates all three sub-forms. If any is invalid, scrolls to the first error and returns early.
- Calls
buildAmountPayload, which merges the share count, the ownership form'sprofile_id, andfundingPayloadinto one object. - Calls
investmentRepository.setAmount, catches errors and surfaces them viareportError. - On success, navigates to the signature step.
Navigation lives here, not in the repository. Error handling lives here, not in the View. The repository gets called and returns. The ViewModel decides what happens with the result.
Now look at what the View does with all this.
Layer 4 — The View
The entire <script setup> is one composable call and a destructure:
<script setup lang="ts">
import { useInvestAmount } from './logic/useInvestAmount.ts';
const {
errorData, schemaBackend, getInvestUnconfirmedOne,
formModel, handleContinue,
amountFormRef, ownershipFormRef, fundingFormRef,
getWalletState, walletId,
getEvmWalletState, evmWalletId,
selectedUserProfileData,
isBtnDisabled, isLoading,
} = useInvestAmount();
</script>
<template>
<div class="ViewInvestAmount">
<InvestStep
title="Investment"
:step-number="1"
:is-loading="isLoading"
:footer="{
cancel: { href: urlOfferSingle(route.params.slug) },
primary: { text: 'Continue', disabled: isBtnDisabled, loading: isLoading },
}"
@footer-primary="handleContinue"
>
<VFormInvestAmount ref="amountFormRef" v-model="formModel" :data="getInvestUnconfirmedOne" ... />
<VFormInvestOwnership ref="ownershipFormRef" v-model="formModel" ... />
<VFormInvestFunding ref="fundingFormRef" v-model="formModel" ... />
</InvestStep>
</div>
</template>The template doesn't know what handleContinue does. It doesn't know which API gets called, which route comes next, or what funding types exist. It binds reactive state to props and delegates user events to the ViewModel. That's the contract.
A good View is boring to read. If you have to understand the business domain to understand the template, something leaked.
The violations are more instructive.
What a violation looks like
The View that knows too much:
<script setup lang="ts">
// Don't do this. The View now owns the URL, auth params, and the next route name.
const handleContinue = async () => {
await fetch(`/auth/invest/${slug}/amount/${id}/${profileId}`, {
method: 'PUT',
body: JSON.stringify(formData.value),
});
router.push({ name: 'invest-signature' });
};
</script>You can't reuse this submit logic from a modal or a different surface. You can't test form validation without rendering the full component. The View has become a script.
The repository that navigates:
// Don't do this. The repository now depends on vue-router.
const setAmount = async (...) => withActionState(setAmountState, async () => {
const response = await apiClient.put(`/auth/invest/.../amount/...`, data);
router.push({ name: ROUTE_INVEST_SIGNATURE }); // wrong layer
return response.data;
});Every test for this repository now needs a router mock. Any consumer that wants a different outcome after saving — navigate to a different step, show a confirmation dialog, stay on the page — is stuck with the decision baked into the data layer.
The layers aren't rules. They're defaults that make future changes boring.
The call stack
Tracing the full path of one button click:
[View] @footer-primary="handleContinue"
↓
[ViewModel] handleContinue()
validateAllForms()
buildAmountPayload() ← form refs + fundingPayload computed
investmentRepository.setAmount(slug, id, profileId, data)
↓
[Repository] withActionState(setAmountState, ...)
setAmountState ← { loading: true }
apiClient.put('/auth/invest/.../amount/...')
↓
[Service] fetch() → network
↑
ApiResponse<{ number_of_shares: number }>
setAmountState ← { loading: false, data: result }
↑
[ViewModel] router.push(ROUTE_INVEST_SIGNATURE)
↑
[View] re-renders from reactive setAmountStateEach boundary is a seam. The Service can be swapped for a mock without touching the repository. The repository can be replaced with a stub without touching the ViewModel. The ViewModel can be tested by calling its functions directly, without rendering anything.
The test file is the receipt.


