Architecting a Real-Time Vue 3 Frontend with Clean Architecture for High-Frequency WebSocket Data and Pinia State Management


The typical approach to integrating WebSockets into a Vue application often leads to a tightly coupled mess. The WebSocket instance is either global or instantiated within a component, its onmessage handler directly commits mutations to a Pinia store, and the UI components become bloated with data parsing logic. This architecture collapses under the weight of complexity and high-frequency data streams, making testing an exercise in extensive mocking and maintenance a significant challenge. We faced this exact scenario: a dashboard required to process and display hundreds of financial instrument ticks per second. The initial prototype coupled the WebSocket client directly to the Pinia store, resulting in severe UI jank and an untestable codebase.

The decision was made to refactor, adopting Clean Architecture principles to enforce separation of concerns. The core tenet is the Dependency Rule: source code dependencies can only point inwards. Application business rules should have no knowledge of the UI, the database, or any external agency. In our frontend context, this translates to our core logic being unaware of Vue, Pinia, or even the existence of WebSockets.

graph TD
    subgraph Presentation [Presentation Layer]
        A[Vue Components & MUI]
    end
    subgraph Application [Application Layer]
        B[Use Cases]
        C[Abstractions/Interfaces]
    end
    subgraph Domain [Domain Layer]
        D[Entities]
    end
    subgraph Infrastructure [Infrastructure Layer]
        E[WebSocket Gateway]
        F[Pinia Store Repository]
    end

    A -- "Calls" --> B
    B -- "Depends on" --> C
    B -- "Uses" --> D
    E -- "Implements" --> C
    F -- "Implements" --> C
    E -- "Calls" --> B

This structure dictates the entire project’s organization and data flow. The pain point wasn’t the technology stack itself—Vue 3, Pinia, and MUI are exceptionally capable—but the undisciplined flow of data from the network layer directly to the state and UI.

Directory Structure and The Domain Layer

A real-world project’s file structure must mirror its architecture. For this implementation, we organized our source code into four primary directories, representing the architectural layers: domain, application, infrastructure, and presentation.

src/
├── domain/
│   └── market/
│       ├── MarketData.ts
│       └── MarketDataRepository.ts
├── application/
│   └── market/
│       └── ProcessMarketDataUpdate.ts
├── infrastructure/
│   ├── market/
│   │   ├── MarketDataPiniaRepository.ts
│   │   └── MarketDataWebSocketGateway.ts
│   └── services/
│       └── WebSocketClient.ts
└── presentation/
    ├── components/
    │   └── MarketDataTable.vue
    └── stores/
        └── market.ts # The Pinia store definition

The innermost layer, domain, is the most stable and has zero external dependencies. It defines the core business objects (Entities) and the interfaces for any repositories that manage them.

src/domain/market/MarketData.ts
This file defines the core data structure. It’s a plain TypeScript type or class with no logic related to frameworks or transport protocols.

// src/domain/market/MarketData.ts

/**
 * Represents a single piece of market data for a financial instrument.
 * This is a pure domain entity. It has no knowledge of how it's fetched,
 * stored, or displayed.
 */
export interface MarketData {
  id: string; // Unique identifier for the instrument, e.g., 'BTC-USD'
  price: number;
  change: number;
  changePercent: number;
  volume: number;
  lastUpdated: number; // Timestamp of the last update
}

src/domain/market/MarketDataRepository.ts
This defines the contract for how market data is managed. The application layer will depend on this interface, not on a specific implementation like Pinia. This is a critical piece of the decoupling strategy.

// src/domain/market/MarketDataRepository.ts

import { MarketData } from './MarketData';

/**
 * Defines the contract for a repository that manages MarketData entities.
 * The application layer's use cases will depend on this abstraction,
 * not a concrete implementation. This allows us to swap out Pinia for
 * another state management library without touching the core business logic.
 */
export interface IMarketDataRepository {
  /**
   * Updates or inserts a batch of market data entities.
   * Using a batch operation is crucial for performance with high-frequency streams.
   * @param data - An array of MarketData entities.
   */
  updateBatch(data: MarketData[]): Promise<void>;

  /**
   * Retrieves a single market data entity by its ID.
   * @param id - The unique identifier of the instrument.
   */
  findById(id: string): Promise<MarketData | undefined>;

  /**
   * Retrieves all market data entities.
   */
  findAll(): Promise<MarketData[]>;
}

The Application Layer: Core Business Logic

This layer contains the application-specific business rules, encapsulated as “Use Cases.” A use case orchestrates the flow of data to and from the domain entities. It is triggered by an external agent (like the WebSocket gateway) and uses the repository interfaces to perform its work.

src/application/market/ProcessMarketDataUpdate.ts
This use case is responsible for handling incoming raw data from an external source, transforming it into a domain entity, and persisting it via the repository. Crucially, it depends on the IMarketDataRepository interface, not the Pinia implementation.

// src/application/market/ProcessMarketDataUpdate.ts

import { MarketData, IMarketDataRepository } from '@/domain/market';

// This could represent the raw data structure coming from the WebSocket server.
export interface RawMarketUpdateDTO {
  i: string;  // instrument id
  p: number;  // price
  c: number;  // change
  cp: number; // change percent
  v: number;  // volume
}

/**
 * Use Case: Processes a raw data update, converts it to a domain entity,
 * and uses the repository to update the application's state.
 *
 * This class contains the core business logic, completely isolated from
 * Vue, Pinia, WebSockets, or any other framework/library. This makes it
 * trivial to unit test.
 */
export class ProcessMarketDataUpdate {
  // The use case depends on the abstraction (interface), not a concrete implementation.
  // This is the Dependency Inversion Principle at work.
  constructor(private readonly marketDataRepository: IMarketDataRepository) {}

  /**
   * Executes the use case.
   * @param updates - An array of raw data transfer objects from the external source.
   */
  async execute(updates: RawMarketUpdateDTO[]): Promise<void> {
    if (!updates || updates.length === 0) {
      // In a real-world project, you'd have more robust validation and logging.
      console.warn('Received an empty update batch.');
      return;
    }

    const processedData: MarketData[] = updates.map(update => {
      // Here you would place validation, transformation, or any other business logic.
      // A common mistake is to put this logic inside components or stores.
      if (!update.i || typeof update.p !== 'number') {
        throw new Error(`Invalid market data received: ${JSON.stringify(update)}`);
      }

      return {
        id: update.i,
        price: update.p,
        change: update.c,
        changePercent: update.cp,
        volume: update.v,
        lastUpdated: Date.now(),
      };
    });

    try {
      // The use case interacts with the domain via the repository interface.
      // It doesn't know or care that this might be a Pinia store underneath.
      await this.marketDataRepository.updateBatch(processedData);
    } catch (error) {
      // Proper error handling and logging would be implemented here.
      console.error('Failed to update market data batch:', error);
      // Depending on the use case, you might need to bubble this up
      // or handle it with a retry mechanism.
    }
  }
}

The Infrastructure Layer: Connecting to the Outside World

This layer contains the concrete implementations of the abstractions defined in the application and domain layers. It’s where frameworks, drivers, and external APIs live.

src/infrastructure/services/WebSocketClient.ts
This is a generic, reusable WebSocket client wrapper. It handles the low-level details of connection, reconnection, and message handling, but it has no business logic. It emits events that a gateway can listen to.

// src/infrastructure/services/WebSocketClient.ts

type WebSocketStatus = 'connecting' | 'open' | 'closing' | 'closed' | 'reconnecting';
type MessageHandler = (data: any) => void;

export class WebSocketClient {
  private ws: WebSocket | null = null;
  private status: WebSocketStatus = 'closed';
  private messageHandler: MessageHandler | null = null;
  private readonly reconnectInterval = 5000; // 5 seconds
  private reconnectTimeoutId: number | null = null;

  constructor(private readonly url: string) {}

  public connect(handler: MessageHandler): void {
    if (this.ws && this.status !== 'closed') {
      console.warn('WebSocket is already connected or connecting.');
      return;
    }
    
    this.messageHandler = handler;
    this.status = 'connecting';
    console.log(`WebSocket: connecting to ${this.url}`);
    
    this.ws = new WebSocket(this.url);

    this.ws.onopen = () => {
      this.status = 'open';
      console.log('WebSocket: connection established.');
      if (this.reconnectTimeoutId) {
        clearTimeout(this.reconnectTimeoutId);
        this.reconnectTimeoutId = null;
      }
    };

    this.ws.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);
        if (this.messageHandler) {
          this.messageHandler(data);
        }
      } catch (error) {
        console.error('WebSocket: failed to parse message.', error);
      }
    };

    this.ws.onerror = (error) => {
      console.error('WebSocket: error occurred.', error);
    };

    this.ws.onclose = () => {
      this.status = 'closed';
      console.log('WebSocket: connection closed. Attempting to reconnect...');
      this.reconnect();
    };
  }

  private reconnect(): void {
    if (this.reconnectTimeoutId) return; // Already scheduled
    
    this.status = 'reconnecting';
    this.reconnectTimeoutId = window.setTimeout(() => {
      this.connect(this.messageHandler!);
    }, this.reconnectInterval);
  }

  public disconnect(): void {
    if (this.reconnectTimeoutId) {
        clearTimeout(this.reconnectTimeoutId);
        this.reconnectTimeoutId = null;
    }
    if (this.ws) {
      this.status = 'closing';
      this.ws.close();
      this.ws = null;
      this.status = 'closed';
      console.log('WebSocket: disconnected by client.');
    }
  }
}

src/infrastructure/market/MarketDataWebSocketGateway.ts
This gateway acts as an adapter. It uses the WebSocketClient to connect to the server. When it receives a message, it doesn’t modify state directly. Instead, it instantiates and executes the ProcessMarketDataUpdate use case, passing the raw data to it. This cleanly separates transport logic from business logic.

// src/infrastructure/market/MarketDataWebSocketGateway.ts

import { WebSocketClient } from '../services/WebSocketClient';
import { ProcessMarketDataUpdate, RawMarketUpdateDTO } from '@/application/market/ProcessMarketDataUpdate';

/**
 * The gateway is an adapter that connects the WebSocket infrastructure
 * to the application's use cases.
 */
export class MarketDataWebSocketGateway {
  private client: WebSocketClient;

  // The gateway depends on the concrete use case.
  constructor(
    private readonly serverUrl: string,
    private readonly processUpdateUseCase: ProcessMarketDataUpdate
  ) {
    this.client = new WebSocketClient(this.serverUrl);
  }

  public startListening(): void {
    this.client.connect(this.handleMessage.bind(this));
  }

  public stopListening(): void {
    this.client.disconnect();
  }

  /**
   * The handler that is called by the WebSocketClient on new messages.
   * Its only job is to pass the data to the application layer.
   * @param data - The raw data from the WebSocket message.
   */
  private handleMessage(data: { type: string, payload: RawMarketUpdateDTO[] }): void {
    // In a real-world scenario, you'd have more complex message routing.
    if (data.type === 'market-update-batch') {
      this.processUpdateUseCase.execute(data.payload);
    }
  }
}

src/infrastructure/market/MarketDataPiniaRepository.ts
Here, we provide the concrete implementation of IMarketDataRepository using Pinia. It bridges the gap between the abstract repository contract and the specific state management library. A critical pitfall with high-frequency updates is triggering too many state changes and component re-renders. A pragmatic solution is to batch updates. Instead of committing every single message, we collect them and flush them to the store at a regular interval using requestAnimationFrame, which aligns updates with the browser’s rendering cycle.

// src/infrastructure/market/MarketDataPiniaRepository.ts

import { IMarketDataRepository, MarketData } from '@/domain/market';
import { useMarketStore } from '@/presentation/stores/market';

/**
 * A Pinia-based implementation of the MarketDataRepository.
 * This class adapts the Pinia store to fit the repository interface
 * defined in the domain layer.
 */
export class MarketDataPiniaRepository implements IMarketDataRepository {
  private marketStore;

  constructor() {
    // The Pinia store must be accessed within a Pinia context,
    // which is typically set up when the Vue app is created.
    this.marketStore = useMarketStore();
  }

  async updateBatch(data: MarketData[]): Promise<void> {
    // This is where the batching magic happens.
    // Instead of calling the action directly, we push to a queue.
    this.marketStore.queueUpdates(data);
  }

  async findById(id: string): Promise<MarketData | undefined> {
    return this.marketStore.getDataById(id);
  }

  async findAll(): Promise<MarketData[]> {
    return this.marketStore.allData;
  }
}

// In your main.ts, you would set up the dependencies.
/*
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

// ...
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)

// --- Dependency Injection Setup (simplified) ---
import { ProcessMarketDataUpdate } from '@/application/market/ProcessMarketDataUpdate';
import { MarketDataPiniaRepository } from '@/infrastructure/market/MarketDataPiniaRepository';
import { MarketDataWebSocketGateway } from '@/infrastructure/market/MarketDataWebSocketGateway';

const marketDataRepository = new MarketDataPiniaRepository();
const processUpdateUseCase = new ProcessMarketDataUpdate(marketDataRepository);
const marketDataGateway = new MarketDataWebSocketGateway('wss://your-websocket-server.com', processUpdateUseCase);

// Make gateway available globally or provide it to the root component
app.provide('marketDataGateway', marketDataGateway);
marketDataGateway.startListening();

app.mount('#app')
*/

src/presentation/stores/market.ts
This is the actual Pinia store. It contains the state and the actions. The batching logic is implemented here.

// src/presentation/stores/market.ts

import { defineStore } from 'pinia';
import { MarketData } from '@/domain/market';

interface MarketState {
  marketData: Record<string, MarketData>;
  updateQueue: MarketData[];
  isUpdateScheduled: boolean;
}

export const useMarketStore = defineStore('market', {
  state: (): MarketState => ({
    marketData: {},
    updateQueue: [],
    isUpdateScheduled: false,
  }),
  getters: {
    allData: (state): MarketData[] => Object.values(state.marketData),
    getDataById: (state) => (id: string): MarketData | undefined => state.marketData[id],
  },
  actions: {
    queueUpdates(updates: MarketData[]) {
      this.updateQueue.push(...updates);

      if (!this.isUpdateScheduled) {
        this.isUpdateScheduled = true;
        // Use requestAnimationFrame to batch state mutations and align them
        // with the browser's render cycle. This is a key performance optimization.
        requestAnimationFrame(this.processQueue);
      }
    },
    
    processQueue() {
      if (this.updateQueue.length === 0) {
        this.isUpdateScheduled = false;
        return;
      }
      
      const updatesToProcess = [...this.updateQueue];
      this.updateQueue = [];

      // A common mistake is to mutate state in a loop, triggering many
      // reactivity updates. Instead, build a new object and assign it.
      // For very large states, a more nuanced approach might be needed,
      // but this is a good starting point.
      const updatedMarketData = { ...this.marketData };
      for (const item of updatesToProcess) {
        updatedMarketData[item.id] = item;
      }
      this.marketData = updatedMarketData;

      this.isUpdateScheduled = false;
    }
  },
});

The Presentation Layer: Dumb Components

The UI layer, built with Vue and MUI, becomes remarkably simple. Components are responsible only for displaying data from the Pinia store and emitting user events. They have no knowledge of business logic or data sources.

src/presentation/components/MarketDataTable.vue
This component uses MUI’s DataGrid to efficiently render the real-time data. It reads directly from the Pinia store’s getters.

<template>
  <div style="height: 600px; width: 100%;">
    <DataGrid
      :rows="marketData"
      :columns="columns"
      :getRowId="(row) => row.id"
      :loading="marketData.length === 0"
    />
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import { DataGrid, GridColDef } from '@mui/x-data-grid';
import { useMarketStore } from '@/presentation/stores/market';

const marketStore = useMarketStore();

// The component is "dumb". It just subscribes to the state via computed properties.
// It doesn't know where the data comes from (WebSocket, HTTP, etc.).
const marketData = computed(() => marketStore.allData);

const columns: GridColDef[] = [
  { field: 'id', headerName: 'Instrument', width: 150 },
  { field: 'price', headerName: 'Price', type: 'number', width: 130 },
  { field: 'change', headerName: 'Change', type: 'number', width: 130 },
  { field: 'changePercent', headerName: 'Change %', type: 'number', width: 130 },
  { field: 'volume', headerName: 'Volume', type: 'number', width: 150 },
  {
    field: 'lastUpdated',
    headerName: 'Last Updated',
    width: 200,
    valueFormatter: (params) => new Date(params.value).toLocaleTimeString(),
  },
];
</script>

Testability: The True Payoff

The primary benefit of this architecture reveals itself during testing. We can now test each layer in isolation. The most valuable tests are for the application layer use cases. Using Jest, we can test our entire business logic without needing a browser, a running WebSocket server, or even Vue itself.

tests/unit/application/ProcessMarketDataUpdate.spec.ts

// tests/unit/application/ProcessMarketDataUpdate.spec.ts

import { ProcessMarketDataUpdate, RawMarketUpdateDTO } from '@/application/market/ProcessMarketDataUpdate';
import { IMarketDataRepository, MarketData } from '@/domain/market';

// Create a mock implementation of the repository interface for testing.
class MockMarketDataRepository implements IMarketDataRepository {
  public data: Record<string, MarketData> = {};
  
  // Jest's mock function allows us to spy on this method.
  public updateBatch = jest.fn(async (updates: MarketData[]): Promise<void> => {
    updates.forEach(item => {
      this.data[item.id] = item;
    });
  });

  public async findById(id: string): Promise<MarketData | undefined> {
    return this.data[id];
  }

  public async findAll(): Promise<MarketData[]> {
    return Object.values(this.data);
  }
}

describe('ProcessMarketDataUpdate Use Case', () => {
  let mockRepository: MockMarketDataRepository;
  let useCase: ProcessMarketDataUpdate;

  beforeEach(() => {
    // Reset mocks before each test to ensure isolation.
    mockRepository = new MockMarketDataRepository();
    useCase = new ProcessMarketDataUpdate(mockRepository);
  });

  it('should process raw updates and call the repository with correctly mapped domain entities', async () => {
    const rawUpdates: RawMarketUpdateDTO[] = [
      { i: 'BTC-USD', p: 50000, c: 100, cp: 0.2, v: 1000 },
      { i: 'ETH-USD', p: 4000, c: -50, cp: -1.25, v: 5000 },
    ];
    
    await useCase.execute(rawUpdates);

    // Assert that the repository's method was called once.
    expect(mockRepository.updateBatch).toHaveBeenCalledTimes(1);
    
    // Assert that the data passed to the repository is correctly transformed.
    const callArgument = mockRepository.updateBatch.mock.calls[0][0];
    expect(callArgument.length).toBe(2);
    expect(callArgument[0]).toEqual(expect.objectContaining({
      id: 'BTC-USD',
      price: 50000,
    }));
    expect(callArgument[1].id).toBe('ETH-USD');

    // We can even check the state of our mock repository.
    const btcData = await mockRepository.findById('BTC-USD');
    expect(btcData?.price).toBe(50000);
  });
  
  it('should throw an error for invalid raw data', async () => {
    const invalidUpdates: any[] = [{ i: 'BTC-USD' }]; // Missing price
    
    // We expect the execute method to throw an error, which we catch.
    await expect(useCase.execute(invalidUpdates)).rejects.toThrow('Invalid market data received');
  });

  it('should not call the repository if the update list is empty', async () => {
    await useCase.execute([]);
    expect(mockRepository.updateBatch).not.toHaveBeenCalled();
  });
});

This test is fast, reliable, and covers the core logic of the application without any external dependencies. This is impossible in a tightly coupled architecture.

While this architectural pattern introduces more files and a degree of boilerplate, its value is realized when the application scales in complexity or team size. The clear boundaries prevent developers from inadvertently creating dependencies that make the system rigid and fragile. The current solution’s main limitation lies in its data processing on the main thread. For extremely high-frequency streams (thousands of updates per second), the requestAnimationFrame batching might still not be enough to prevent main thread contention. A future iteration would explore offloading the initial data parsing and transformation logic into a Web Worker, which would receive raw WebSocket messages, process them into domain entities, and then post the batched, structured data back to the main thread for the Pinia repository to consume. This would ensure the UI remains completely fluid, regardless of the incoming data velocity.


  TOC