How to Structure Enterprise Vue Applications: Scalable Folder Architecture

Architecture guide for large Vue applications. Learn folder structure, dependency injection, composables patterns, naming conventions, and scaling to 100K+ lines of code.

The Folder Structure Problem

I've seen three patterns:

Chaos Model (what startups do):

code
src/
├── components/
│   ├── Button.vue
│   ├── Modal.vue
│   ├── UserCard.vue
│   └── SomeRandomComponent.vue
├── views/
│   ├── Home.vue
│   └── Dashboard.vue
└── utils.js

After 6 months, nobody remembers where anything is.

Rails Model (what some teams copy):

code
src/
├── views/
├── components/
├── composables/
├── stores/
├── utils/
└── services/

Better, but still unclear what belongs where.

Domain-Driven Model (what scales):

code
src/
├── domains/
│   ├── users/
│   │   ├── components/
│   │   ├── composables/
│   │   ├── stores/
│   │   ├── types/
│   │   └── api.ts
│   ├── billing/
│   └── products/
├── shared/
│   ├── components/
│   ├── composables/
│   ├── types/
│   └── utils/
└── app.vue

We'll focus on the domain-driven model.

The Domain-Driven Structure

Organize by business domain, not technical layer:

code
src/
├── domains/
│   │
│   ├── users/
│   │   ├── api.ts              # API calls for users
│   │   ├── types.ts            # User types (User, CreateUserDTO)
│   │   ├── composables/
│   │   │   ├── useUser.ts      # Single user fetch/update
│   │   │   ├── useUserList.ts  # List with pagination
│   │   │   └── useAuth.ts      # Authentication
│   │   ├── stores/
│   │   │   └── userStore.ts    # Pinia store
│   │   ├── components/
│   │   │   ├── UserCard.vue
│   │   │   ├── UserForm.vue
│   │   │   └── UserAvatar.vue
│   │   ├── pages/
│   │   │   ├── UserDetail.vue
│   │   │   └── UserList.vue
│   │   └── index.ts            # Public API
│   │
│   ├── billing/
│   │   ├── api.ts
│   │   ├── types.ts            # Invoice, Subscription types
│   │   ├── composables/
│   │   ├── stores/
│   │   ├── components/
│   │   ├── pages/
│   │   └── index.ts
│   │
│   └── products/
│       ├── api.ts
│       ├── types.ts
│       ├── composables/
│       ├── stores/
│       ├── components/
│       ├── pages/
│       └── index.ts
│
├── shared/
│   ├── components/             # Reusable UI (Button, Modal, etc.)
│   │   ├── Button.vue
│   │   ├── Modal.vue
│   │   └── Pagination.vue
│   ├── composables/            # Shared logic (useLocalStorage, etc.)
│   │   ├── useFetch.ts
│   │   ├── useLocalStorage.ts
│   │   └── useDebounce.ts
│   ├── types/
│   │   ├── index.ts
│   │   └── api.ts
│   ├── utils/
│   │   ├── formatters.ts
│   │   ├── validators.ts
│   │   └── helpers.ts
│   ├── stores/
│   │   └── appStore.ts         # Global app state
│   ├── middleware/
│   │   ├── auth.ts
│   │   └── logging.ts
│   └── index.ts                # Barrel export
│
├── router/
│   ├── index.ts
│   ├── guards.ts
│   └── routes.ts
│
├── styles/
│   ├── main.css
│   ├── variables.css
│   └── utilities.css
│
├── plugins/
│   └── index.ts
│
└── app.vue

Benefits:

  • Feature cohesion — all code for "users" is in one place
  • Independent shipping — one team owns domains/users, another owns domains/billing
  • Clear dependencies — easy to see what each domain needs
  • Scaling — add new domains without touching existing ones

The Barrel Export Pattern

Each domain exports a public API:

typescript
// domains/users/index.ts
export * from './types'
export { default as useUser } from './composables/useUser'
export { default as useUserList } from './composables/useUserList'
export { default as UserCard } from './components/UserCard.vue'
export { default as UserForm } from './components/UserForm.vue'
export { useUserStore } from './stores/userStore'
export * as userApi from './api'

Usage:

typescript
// Importing from domain is clean
import { useUser, UserCard, userApi } from '@/domains/users'

// Instead of scattered imports
import useUser from '@/domains/users/composables/useUser'
import UserCard from '@/domains/users/components/UserCard.vue'
import * as userApi from '@/domains/users/api'

Enforce this with eslint:

javascript
// .eslintrc.js
{
  rules: {
    'import/no-restricted-paths': [
      'error',
      {
        zones: [
          {
            target: './src/domains/users',
            from: './src/domains/billing',
            message: 'Users domain cannot import from Billing domain'
          }
        ]
      }
    ]
  }
}

Composables: The Shared Logic

Composables are where reusable logic lives:

typescript
// domains/users/composables/useUser.ts
import { ref, computed } from 'vue'
import type { User } from '../types'
import * as userApi from '../api'

export const useUser = (userId: string) => {
  const user = ref<User | null>(null)
  const loading = ref(false)
  const error = ref<string | null>(null)

  const fetchUser = async () => {
    loading.value = true
    try {
      user.value = await userApi.getUser(userId)
      error.value = null
    } catch (err) {
      error.value = (err as Error).message
      user.value = null
    } finally {
      loading.value = false
    }
  }

  const updateUser = async (updates: Partial<User>) => {
    try {
      user.value = await userApi.updateUser(userId, updates)
      error.value = null
    } catch (err) {
      error.value = (err as Error).message
    }
  }

  onMounted(() => fetchUser())

  return {
    user: readonly(user),
    loading: readonly(loading),
    error: readonly(error),
    fetchUser,
    updateUser
  }
}

Use it:

vue
<script setup lang="ts">
import { useUser } from '@/domains/users'

const route = useRoute()
const { user, loading, error } = useUser(route.params.id)
</script>

<template>
  <div v-if="loading">Loading...</div>
  <div v-else-if="error">{{ error }}</div>
  <div v-else>{{ user?.name }}</div>
</template>

Dependency Injection

For large apps, inject dependencies instead of hardcoding:

typescript
// shared/services/api.ts
class ApiService {
  private baseUrl: string

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl
  }

  async get(path: string) {
    return fetch(`${this.baseUrl}${path}`).then(r => r.json())
  }
}

// main.ts
import { createApp } from 'vue'
import ApiService from './shared/services/api'

const app = createApp(App)

app.provide('api', new ApiService(import.meta.env.VITE_API_URL))

app.mount('#app')

Use it:

typescript
// domains/users/api.ts
import { inject } from 'vue'

export const useUserApi = () => {
  const api = inject<ApiService>('api')!
  
  return {
    getUser: (id: string) => api.get(`/users/${id}`),
    listUsers: () => api.get('/users'),
    createUser: (data: any) => api.post('/users', data)
  }
}

Types Organization

Centralize types:

typescript
// domains/users/types.ts
export interface User {
  id: string
  name: string
  email: string
  avatar?: string
  role: 'user' | 'admin'
  createdAt: string
  updatedAt: string
}

export interface CreateUserDTO {
  name: string
  email: string
  password: string
  role?: 'user' | 'admin'
}

export interface UpdateUserDTO {
  name?: string
  email?: string
  avatar?: string
}

export interface UserListResponse {
  data: User[]
  total: number
  page: number
  pageSize: number
}

Never use inline interfaces.

Naming Conventions

typescript
// Components: PascalCase
UserCard.vue
UserForm.vue
CreateUserModal.vue

// Composables: useCamelCase
useUser.ts
useUserList.ts
useUserForm.ts
useDebounce.ts

// Stores: Store suffix
userStore.ts
authStore.ts
appStore.ts

// API files: lowercase
api.ts (or users-api.ts)

// Types: PascalCase
User.ts
CreateUserDTO.ts

// Utils: camelCase, descriptive
formatDate.ts
validateEmail.ts
calculateDiscount.ts

Growing the Structure

As you add more code, follow these patterns:

code
domains/users/ (20 KB)
  ├── api.ts
  ├── types.ts
  ├── composables/ (3 files)
  ├── components/ (5 files)
  └── pages/ (2 files)

# When it gets too big (50+ KB), break it down:

domains/
├── users/
│   ├── list/            # User listing feature
│   ├── detail/          # User detail/edit feature
│   ├── auth/            # Auth-specific logic
│   └── shared/          # Shared user types/api

Scaling to 100K+ Lines

At scale, add:

code
src/
├── domains/
├── features/            # Cross-domain workflows
│   ├── onboarding/      # Onboarding flow uses Users + Billing
│   ├── migration/       # Data migration uses multiple domains
│   └── reporting/
├── shared/
├── router/
└── layouts/             # Layout components

Key Rules

  1. Keep domains independent — Users domain can't import from Billing
  2. Shared is truly shared — only reusable across domains
  3. Use barrel exports — clean public APIs
  4. Type everything — centralize types by domain
  5. One entry point — each domain exports from index.ts
  6. Enforce with linting — catch violations early

This structure scales from 10 engineers to 100. Your codebase will thank you.