from feature slices to clean module boundaries
Anyone who builds Vue projects without a well-thought-out file structure ends up fighting unclear ownership and circular dependencies by the time a second developer joins. Feature-based Vue architecture with clearly defined layers and composables as its core solves this problem structurally, before it slows down everyday work.
Table of Contents
- 1. Why classic folder structures fail in Vue
- 2. Feature slicing: the core of file-based Vue architecture
- 3. Layers within a feature slice
- 4. Composables as the core of business logic
- 5. Shared layer: what may truly be shared
- 6. Routing and lazy loading per feature
- 7. Organizing Pinia stores cleanly
- 8. Enforcing module boundaries and checking dependencies
- 9. Architecture approaches compared
- 10. Summary
- 11. FAQ
1. Why classic folder structures fail in Vue
The classic Vue project structure with components/, views/, store/, services/ and utils/ as top-level folders works well for small applications with a single developer and a manageable scope. As soon as the project grows, these folders turn into drawers that everything gets stuffed into. A simple feature request then leads to files being created or changed in four different folders, a clear sign that the **Vue architecture** is organized by technical type rather than by responsibility.
The concrete problem: after a year, components/ holds thirty to fifty components with no recognizable relationship to one another. A new developer cannot tell which component belongs to which feature, which ones are shared and which are only used by a single view. At the same time, the Pinia store grows into a monolithic object holding state for completely unrelated features. File-based **Vue architecture** solves this problem by starting from a different premise: not the technical type but the business domain determines the folder structure.
2. Feature slicing: the core of file-based Vue architecture
Feature slicing means that every business feature gets its own folder under src/features/. A feature here is anything that represents a self-contained domain: features/auth/, features/catalog/, features/checkout/, features/user-profile/. Inside this feature folder live all the associated components, composables, stores, API calls and types. The result is a **Vue architecture** in which deleting or refactoring a feature comes with minimal side effects, because all the relevant files live in one place.
The boundary between features follows the principle of loose coupling: a feature must never directly access the internal code of another feature. Communication between features happens either through the shared/ layer, through event buses, or through a shared Pinia store that is explicitly marked as shared. This rule, consistently followed, prevents circular dependencies from forming, the most common maintenance problem in grown Vue projects without a well-thought-out **Vue architecture**.
// Recommended directory structure for scalable Vue 3 projects
src/
├── features/
│ ├── auth/
│ │ ├── components/ // LoginForm.vue, AuthGuard.vue
│ │ ├── composables/ // useAuth.ts, usePermissions.ts
│ │ ├── stores/ // authStore.ts
│ │ ├── api/ // authApi.ts
│ │ ├── types/ // auth.types.ts
│ │ └── index.ts // public API of the feature
│ ├── catalog/
│ │ ├── components/
│ │ ├── composables/
│ │ ├── stores/
│ │ ├── api/
│ │ └── index.ts
│ └── checkout/
│ └── ...
├── shared/
│ ├── components/ // Button, Modal, Input, truly reusable
│ ├── composables/ // useDebounce, usePagination
│ ├── utils/ // formatDate, slugify
│ └── types/ // global.types.ts
├── app/
│ ├── router/ // route definitions, lazy imports
│ ├── stores/ // cross-feature stores only
│ └── App.vue
└── main.ts
3. Layers within a feature slice
Inside every feature folder there is a clear layered architecture that works from the outside in. The outermost layer is the Vue components in components/, which are purely presentational and contain as little logic as possible. Components call composables, read reactive data from them, and forward user input. This layer is easy to test with Testing Library because it has no direct dependency on API calls or global state. This separation makes the **Vue architecture** more resilient to refactorings in the data layer.
The middle layer consists of the composables: they hold local state, coordinate API calls through the API layer, and provide reactive data to the template. Composables are the right place for business logic that belongs neither in the component nor in the global store. The innermost layer is the API layer in api/: pure functions that trigger HTTP requests and return typed responses. They know nothing about Vue reactivity and are easy to test with regular unit tests. Every feature's index.ts exports only what other features or the router actually need, the internal structure stays private.
4. Composables as the core of business logic
Composables are the most powerful tool in Vue 3's **Vue architecture**. They replace mixins, which were the main mechanism for reusing logic in Vue 2, and solve their problems: no implicit naming conflicts, no unclear origin of reactive data, full TypeScript support. A well-written composable is a function that encapsulates ref, computed and Vue lifecycle hooks and exposes a clearly defined interface. By convention the name always starts with use, which immediately signals membership in the composables layer.
The most important design decision when writing composables is whether they hold their own state or receive state as a parameter. A composable that calls ref() internally creates a new state instance on every call, good for form logic that should be isolated per component instance. A composable that imports a Pinia store implicitly shares its state with every caller. In the **Vue architecture** it is important not to mix these two patterns: composables with local state for feature-internal logic, store-backed composables for cross-feature data.
// features/catalog/composables/useProductList.ts
// Composable with local state, each caller gets its own instance
import { ref, computed, onMounted } from 'vue'
import { fetchProducts } from '../api/catalogApi'
import type { Product, ProductFilter } from '../types/catalog.types'
export function useProductList(initialFilter: ProductFilter = {}) {
// Local reactive state, not shared between callers
const products = ref<Product[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const filter = ref<ProductFilter>(initialFilter)
// Derived state, automatically updates when products or filter changes
const filteredCount = computed(() => products.value.length)
async function loadProducts() {
isLoading.value = true
error.value = null
try {
products.value = await fetchProducts(filter.value)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Unknown error'
} finally {
isLoading.value = false
}
}
function updateFilter(newFilter: Partial<ProductFilter>) {
filter.value = { ...filter.value, ...newFilter }
loadProducts()
}
// Lifecycle hook inside composable, no need to call in component
onMounted(loadProducts)
return { products, isLoading, error, filteredCount, updateFilter }
}
5. Shared layer: what may truly be shared
The shared/ layer is the most dangerous zone in any **Vue architecture**, because it can become a dumping ground for everything a developer doesn't want to assign to a specific feature. The rule for the shared layer must be stated clearly: a file belongs in shared/ only if it is used by at least three independent features and contains no feature-specific logic. A button, a modal, a pagination composable and a date formatter belong there. An API call that happens to be used by two features, on the other hand, belongs to the feature that carries primary responsibility for that data.
Shared components follow the principle of maximum genericity: they accept all variable content via props and slots and have no opinion about the domain. A shared/components/DataTable.vue doesn't know whether it displays products or orders, that is dictated by the caller through typed props. This abstraction pays off when rendering behavior can be adjusted in one central place without touching every feature component. The **Vue architecture** gains consistency this way and reduces duplicate code without compromising feature isolation.
6. Routing and lazy loading per feature
In a file-based **Vue architecture**, every feature defines its own routes and registers them with the central router. The classic pattern is a routes.ts file in the feature folder that exports an array of RouteRecordRaw objects. The central router in app/router/index.ts imports these arrays and merges them. The result is a router setup that needs only one import line for a new feature, without having to fundamentally touch the existing router configuration.
Lazy loading is simple and consistent to implement with this approach: every view component is loaded with a dynamic import function, so the Vite bundler automatically creates a separate chunk for each feature. The initial bundle becomes noticeably smaller, because only the code actually needed for the current route is loaded. In the **Vue architecture** this means: as a feature grows through new subpages, only that feature's chunk grows, the rest of the application stays unchanged. Route guards that access a feature's own authentication logic are likewise defined in that feature's own routes.ts.
7. Organizing Pinia stores cleanly
Pinia is the official state manager for Vue 3 and fits naturally into a feature-slicing **Vue architecture**. The basic rule: every feature store belongs in features/[name]/stores/ and contains only the state that belongs to that feature. Store definitions using Pinia's Composition API syntax are preferable, because they work seamlessly with composables and pass TypeScript types through without extra wrapper types.
Stores that need to be shared across features, such as a user store or a notification store, belong in app/stores/ and are explicitly marked as global. Feature stores may import global stores, but global stores must never import feature stores: this one-way street prevents circular dependencies in the **Vue architecture** at the store level. If a feature store grows too large and manages several unrelated areas of state, that's a clear signal to split the feature further or use composables to encapsulate state.
// features/auth/stores/authStore.ts
// Pinia store using Composition API syntax, type-safe and composable-friendly
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import type { User } from '../types/auth.types'
import { loginRequest, logoutRequest } from '../api/authApi'
export const useAuthStore = defineStore('auth', () => {
// State
const user = ref<User | null>(null)
const token = ref<string | null>(localStorage.getItem('auth_token'))
// Getters
const isAuthenticated = computed(() => token.value !== null && user.value !== null)
const userDisplayName = computed(() => user.value?.name ?? 'Guest')
// Actions
async function login(email: string, password: string) {
const response = await loginRequest({ email, password })
token.value = response.token
user.value = response.user
localStorage.setItem('auth_token', response.token)
}
async function logout() {
await logoutRequest()
token.value = null
user.value = null
localStorage.removeItem('auth_token')
}
return { user, token, isAuthenticated, userDisplayName, login, logout }
})
8. Enforcing module boundaries and checking dependencies
The best **Vue architecture** is of little use if nobody enforces the defined boundaries. In small teams a documented convention is often enough, but from four or more developers onward an automated tool is recommended. ESLint with the eslint-plugin-import plugin can, with the right configuration, prevent Feature A from directly importing the internal components of Feature B. The rule: always import only through index.ts, never from subpaths of other features. An import like import X from '@/features/auth/components/LoginForm.vue' from another feature is a boundary violation.
It's also worth integrating madge or dependency-cruiser into the CI pipeline. These tools visualize and validate the project's dependency graph. A configuration that flags circular dependencies and forbidden cross-feature imports as errors prevents the **Vue architecture** from eroding over time. In the pull request you immediately see when a new feature import violates the defined module boundaries, and can course-correct before the pattern spreads across the entire codebase.
9. Architecture approaches compared
There are several well-known approaches to structuring Vue projects. The right choice depends on team size, feature count and long-term perspective.
| Approach | Structure | Scales to | Main problem |
|---|---|---|---|
| Type-based | components/, views/, store/ |
1-2 developers, <10 features | Unclear ownership as it grows |
| Feature slicing | features/[name]/… |
Team of 5-15 people | Requires discipline on the shared layer |
| Monorepo (Nx/Turborepo) | Separate packages per feature | Enterprise, many teams | High setup and tooling overhead |
| Nuxt modules | Nuxt conventions + modules | SSR projects with Nuxt | Only makes sense with Nuxt |
| DDD layers | Domain, Application, Infrastructure | Complex domain logic | Overhead for small teams |
For most Vue projects that outgrow the prototype phase, feature slicing is the most pragmatic approach. It brings the benefits of a clear **Vue architecture** without the overhead of a full monorepo setup. The move to a monorepo approach makes sense when features are developed by different teams with different deployment cycles, only then does physical package separation pay off.
Mironsoft
Vue 3 architecture, frontend consulting and scaling advice
Is your Vue project growing without a clear architecture?
We analyze existing Vue projects, identify architectural weak points, and guide you step by step toward a feature-based structure, without interrupting ongoing operations.
Architecture review
Analysis of the existing Vue structure for dependencies and scaling obstacles
Refactoring plan
Step-by-step migration to feature slicing without a big-bang rewrite
Team workshop
Documenting architecture conventions and handing them over to the team
10. Summary
File-based **Vue architecture** with feature slicing solves the fundamental problem of grown Vue projects: unclear ownership, circular dependencies and poor maintainability. Every feature gets its own folder with components, composables, store, API layer and types. The shared layer contains only genuine, domain-free abstractions. Composables take over the business logic and keep local state cleanly separated from global Pinia state. Routing and lazy loading are organized per feature.
The decisive step is not the folder structure alone but enforcing the module boundaries: no cross-feature import bypassing internal components, no state in global stores that belongs to a single feature. Automation through ESLint rules and dependency-cruiser in the CI pipeline ensures that the **Vue architecture** is preserved long-term, even as the team grows and new developers join who don't know the original design decisions.
File-based Vue architecture, the essentials at a glance
Feature slicing
Every feature has its own folder under features/ with components, composables, store and API. No cross-feature imports bypassing index.ts.
Composables
Composables encapsulate business logic and reactive state. Local state via ref(), global state via Pinia stores, never mix them.
Shared layer
Only abstractions used by at least three features and containing no feature logic belong in shared/.
Enforce boundaries
ESLint rules and dependency-cruiser in CI prevent module boundaries from eroding over time, automatically, without manual code reviews.