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):
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):
src/
├── views/
├── components/
├── composables/
├── stores/
├── utils/
└── services/
Better, but still unclear what belongs where.
Domain-Driven Model (what scales):
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:
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 ownsdomains/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:
// 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:
// 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:
// .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:
// 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:
<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:
// 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:
// 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:
// 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
// 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:
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:
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
- Keep domains independent — Users domain can't import from Billing
- Shared is truly shared — only reusable across domains
- Use barrel exports — clean public APIs
- Type everything — centralize types by domain
- One entry point — each domain exports from
index.ts - Enforce with linting — catch violations early
This structure scales from 10 engineers to 100. Your codebase will thank you.