von Feature-Slices bis zu sauberen Modul-Grenzen
Wer Vue-Projekte ohne durchdachte Dateistruktur aufbaut, kämpft spätestens beim zweiten Entwickler mit unklaren Verantwortlichkeiten und zirkulären Abhängigkeiten. Feature-basierte Vue-Architektur mit klar definierten Schichten und Composables als Herzstück löst dieses Problem strukturell, bevor es den Alltag verlangsamt.
Inhaltsverzeichnis
- 1. Warum klassische Ordnerstruktur in Vue scheitert
- 2. Feature-Slicing: der Kern dateibasierter Vue-Architektur
- 3. Schichten innerhalb eines Feature-Slice
- 4. Composables als Herzstück der Geschäftslogik
- 5. Shared-Layer: was wirklich geteilt werden darf
- 6. Routing und Lazy-Loading pro Feature
- 7. Pinia-Stores sauber organisieren
- 8. Modul-Grenzen durchsetzen und Abhängigkeiten prüfen
- 9. Architektur-Ansätze im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum klassische Ordnerstruktur in Vue scheitert
Die klassische Vue-Projektstruktur mit components/, views/, store/, services/ und utils/ als Top-Level-Ordner funktioniert gut für kleine Anwendungen mit einem Entwickler und einem überschaubaren Scope. Sobald das Projekt wächst, werden diese Ordner zu Schubladen, in die alles hineingestopft wird. Eine einfache Feature-Anforderung führt dann dazu, dass Dateien in vier verschiedenen Ordnern angelegt oder geändert werden müssen — ein klares Zeichen, dass die **Vue-Architektur** nicht nach Zuständigkeit, sondern nach technischem Typ organisiert ist.
Das konkrete Problem: components/ enthält nach einem Jahr dreißig bis fünfzig Komponenten ohne erkennbare Beziehungen zueinander. Ein neuer Entwickler kann nicht erkennen, welche Komponente zu welchem Feature gehört, welche shared ist und welche nur von einer einzigen View verwendet wird. Gleichzeitig wächst der Pinia-Store zu einem monolithischen Objekt, das Zustand für vollkommen unverwandte Features hält. Die dateibasierte **Vue-Architektur** löst dieses Problem durch einen anderen Ausgangspunkt: Nicht der technische Typ, sondern die fachliche Zugehörigkeit bestimmt die Ordnerstruktur.
2. Feature-Slicing: der Kern dateibasierter Vue-Architektur
Feature-Slicing bedeutet, dass jedes fachliche Feature einen eigenen Ordner unter src/features/ erhält. Ein Feature ist dabei alles, was eine abgeschlossene Domäne abbildet: features/auth/, features/catalog/, features/checkout/, features/user-profile/. Innerhalb dieses Feature-Ordners liegen alle zugehörigen Komponenten, Composables, Stores, API-Calls und Typen. Das Ergebnis ist eine **Vue-Architektur**, in der das Löschen oder Refactoring eines Features mit minimalen Seiteneffekten verbunden ist, weil alle relevanten Dateien an einem Ort liegen.
Die Abgrenzung zwischen Features folgt dem Prinzip der schwachen Kopplung: Ein Feature darf nicht direkt auf den internen Code eines anderen Features zugreifen. Kommunikation zwischen Features läuft entweder über die shared/-Schicht, über Event-Busse oder über einen gemeinsamen Pinia-Store, der explizit als geteilt markiert ist. Diese Regel, konsequent eingehalten, verhindert das Entstehen von zirkulären Abhängigkeiten — dem häufigsten Wartungsproblem in gewachsenen Vue-Projekten ohne durchdachte **Vue-Architektur**.
// 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. Schichten innerhalb eines Feature-Slice
Innerhalb jedes Feature-Ordners gibt es eine klare Schichtenarchitektur, die von außen nach innen arbeitet. Die äußerste Schicht sind die Vue-Komponenten in components/ — sie sind rein präsentational und enthalten so wenig Logik wie möglich. Komponenten rufen Composables auf, lesen reaktive Daten daraus und geben Benutzereingaben weiter. Diese Schicht ist leicht testbar mit Testing Library, weil sie keine direkte Abhängigkeit auf API-Calls oder globale Zustände hat. Die **Vue-Architektur** wird durch diese Trennung stabiler gegen Refactorings in der Datenschicht.
Die mittlere Schicht bilden die Composables: Sie halten den lokalen Zustand, koordinieren API-Calls über die API-Schicht und stellen dem Template reaktive Daten zur Verfügung. Composables sind die richtige Stelle für Geschäftslogik, die weder in die Komponente noch in den globalen Store gehört. Die innerste Schicht ist die API-Schicht in api/: reine Funktionen, die HTTP-Requests auslösen und typisierte Antworten zurückgeben. Sie wissen nichts von Vue-Reaktivität und sind mit regulären Unit-Tests einfach testbar. Die index.ts jedes Features exportiert ausschließlich das, was andere Features oder der Router brauchen — die interne Struktur bleibt privat.
4. Composables als Herzstück der Geschäftslogik
Composables sind das mächtigste Werkzeug der **Vue-Architektur** in Vue 3. Sie ersetzen Mixins, die in Vue 2 die Hauptmethode zur Logik-Wiederverwendung waren, und lösen deren Probleme: keine impliziten Namenskonflikte, keine unklare Herkunft reaktiver Daten, volle TypeScript-Unterstützung. Ein gut geschriebenes Composable ist eine Funktion, die ref, computed und Vue-Lifecycle-Hooks kapselt und ein klar definiertes Interface nach außen gibt. Der Name beginnt per Konvention immer mit use, was die Zugehörigkeit zur Composables-Schicht sofort erkennbar macht.
Die wichtigste Design-Entscheidung bei Composables ist, ob sie ihren eigenen Zustand halten oder Zustand als Parameter erhalten. Ein Composable, das intern ref() aufruft, erstellt bei jedem Aufruf eine neue Zustandsinstanz — gut für Formular-Logik, die pro Komponenten-Instanz isoliert sein soll. Ein Composable, das einen Pinia-Store importiert, teilt seinen Zustand implizit mit allen Aufruferinnen. In der **Vue-Architektur** ist es wichtig, diese beiden Patterns nicht zu vermischen: Composables mit lokalem Zustand für Feature-interne Logik, Store-gestützte Composables für Feature-übergreifende Daten.
// 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: was wirklich geteilt werden darf
Die shared/-Schicht ist die gefährlichste Zone in jeder **Vue-Architektur**, weil sie zur Müllhalde für alles werden kann, das ein Entwickler nicht einem konkreten Feature zuordnen will. Die Regel für den Shared-Layer muss klar formuliert sein: Eine Datei gehört in shared/ nur dann, wenn sie von mindestens drei unabhängigen Features genutzt wird und keine Feature-spezifische Logik enthält. Ein Button, ein Modal, ein Pagination-Composable und ein Datums-Formatter gehören dort hin. Ein API-Call, der zufällig von zwei Features genutzt wird, gehört dagegen ins Feature, das die primäre Verantwortung für diese Daten trägt.
Shared-Komponenten folgen dem Prinzip der maximalen Generizität: Sie nehmen alle variablen Inhalte über Props und Slots entgegen und haben keine Meinung über die Domäne. Ein shared/components/DataTable.vue weiß nicht, ob er Produkte oder Bestellungen anzeigt — das gibt der Aufrufer über typisierte Props vor. Diese Abstraktion zahlt sich aus, wenn sich das Rendering-Verhalten an einer zentralen Stelle anpassen lässt, ohne alle Feature-Komponenten zu ändern. Die **Vue-Architektur** gewinnt dadurch an Konsistenz und reduziert doppelten Code, ohne die Feature-Isolation zu gefährden.
6. Routing und Lazy-Loading pro Feature
In einer dateibasierten **Vue-Architektur** definiert jedes Feature seine eigenen Routen und registriert sie beim zentralen Router. Das klassische Pattern ist eine routes.ts-Datei im Feature-Ordner, die ein Array von RouteRecordRaw-Objekten exportiert. Der zentrale Router in app/router/index.ts importiert diese Arrays und fügt sie zusammen. Das Ergebnis ist ein Router-Setup, das bei neuen Features nur eine Import-Zeile benötigt, ohne die bestehende Router-Konfiguration grundlegend anfassen zu müssen.
Lazy-Loading ist bei diesem Ansatz einfach und konsequent umzusetzen: Jede View-Komponente wird mit einer dynamischen Import-Funktion geladen, sodass der Vite-Bundler für jedes Feature automatisch einen separaten Chunk erstellt. Der initiale Bundle wird dadurch deutlich kleiner, weil nur der Code geladen wird, der für die aktuelle Route tatsächlich benötigt wird. In der **Vue-Architektur** bedeutet das: wächst ein Feature durch neue Unterseiten, wächst nur der Chunk dieses Features — der Rest der Anwendung bleibt unverändert. Route-Guards, die auf die Feature-eigene Authentifizierungslogik zugreifen, werden ebenfalls in der Feature-eigenen routes.ts definiert.
7. Pinia-Stores sauber organisieren
Pinia ist der offizielle State-Manager für Vue 3 und fügt sich natürlich in die Feature-Slicing-**Vue-Architektur** ein. Die Grundregel: Jeder Feature-Store gehört in features/[name]/stores/ und enthält nur den Zustand, der diesem Feature gehört. Store-Definitionen mit der Composition-API-Syntax von Pinia sind dabei zu bevorzugen, weil sie nahtlos mit Composables zusammenarbeiten und TypeScript-Typen ohne zusätzliche Wrapper-Typen durchreichen.
Stores, die Feature-übergreifend geteilt werden müssen — wie ein User-Store oder ein Notification-Store — gehören in app/stores/ und werden explizit als global markiert. Feature-Stores dürfen globale Stores importieren, aber globale Stores dürfen keine Feature-Stores importieren: Diese Einbahnstraße verhindert zirkuläre Abhängigkeiten in der **Vue-Architektur** auf Store-Ebene. Wenn ein Feature-Store zu groß wird und mehrere unverwandte Zustandsbereiche verwaltet, ist das ein klares Signal, das Feature weiter aufzuteilen oder Composables zur Zustandskapselung einzusetzen.
// 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. Modul-Grenzen durchsetzen und Abhängigkeiten prüfen
Die beste **Vue-Architektur** nützt wenig, wenn niemand die definierten Grenzen durchsetzt. In kleinen Teams reicht oft eine dokumentierte Konvention, aber ab vier oder mehr Entwicklern empfiehlt sich ein automatisiertes Werkzeug. ESLint mit dem Plugin eslint-plugin-import kann durch entsprechende Konfiguration verhindern, dass Feature-A direkt in die internen Komponenten von Feature-B importiert. Die Regel: Immer nur über index.ts importieren, nie aus Unterpfaden anderer Features. Ein Import wie import X from '@/features/auth/components/LoginForm.vue' von einem anderen Feature aus ist eine Grenzverletzung.
Zusätzlich lohnt es sich, madge oder dependency-cruiser in die CI-Pipeline zu integrieren. Diese Werkzeuge visualisieren und validieren den Dependency-Graphen des Projekts. Eine Konfiguration, die zirkuläre Abhängigkeiten und verbotene Cross-Feature-Imports als Fehler markiert, verhindert, dass die **Vue-Architektur** über die Zeit erodiert. Im Pull-Request sieht man sofort, wenn ein neuer Feature-Import gegen die definierten Modul-Grenzen verstößt, und kann gegensteuern, bevor sich das Muster im gesamten Codebase ausbreitet.
9. Architektur-Ansätze im Vergleich
Es gibt mehrere bekannte Ansätze zur Strukturierung von Vue-Projekten. Die richtige Wahl hängt von Teamgröße, Feature-Anzahl und Langzeitperspektive ab.
| Ansatz | Struktur | Skaliert bis | Hauptproblem |
|---|---|---|---|
| Typ-basiert | components/, views/, store/ |
1–2 Entwickler, <10 Features | Unklare Zugehörigkeit bei Wachstum |
| Feature-Slicing | features/[name]/… |
Team mit 5–15 Personen | Disziplin bei Shared-Layer nötig |
| Monorepo (Nx/Turborepo) | Separate Packages pro Feature | Enterprise, viele Teams | Hoher Setup- und Tooling-Aufwand |
| Nuxt-Module | Nuxt-Konventionen + Module | SSR-Projekte mit Nuxt | Nur mit Nuxt sinnvoll einsetzbar |
| DDD-Schichten | Domain, Application, Infrastructure | Komplexe Domänen-Logik | Overhead für kleine Teams |
Für die meisten Vue-Projekte, die über die Prototyp-Phase hinauswachsen, ist Feature-Slicing der pragmatischste Ansatz. Er bringt die Vorteile einer klaren **Vue-Architektur** ohne den Overhead eines vollständigen Monorepo-Setups. Die Grenze zum Monorepo-Ansatz ist sinnvoll, wenn Features von verschiedenen Teams mit unterschiedlichen Deployment-Zyklen entwickelt werden — dann macht die physische Pakettrennung Sinn.
Mironsoft
Vue 3 Architektur, Frontend-Consulting und Skalierungs-Beratung
Vue-Projekt ohne klare Architektur am Wachsen?
Wir analysieren bestehende Vue-Projekte, erkennen Architektur-Schwachstellen und führen schrittweise zu einer Feature-basierten Struktur – ohne den laufenden Betrieb zu unterbrechen.
Architektur-Review
Analyse der bestehenden Vue-Struktur auf Abhängigkeiten und Skalierungshindernisse
Refactoring-Plan
Schrittweise Migration zu Feature-Slicing ohne Big-Bang-Rewrite
Team-Workshop
Architektur-Konventionen dokumentieren und ins Team übertragen
10. Zusammenfassung
Die dateibasierte **Vue-Architektur** mit Feature-Slicing löst das fundamentale Problem gewachsener Vue-Projekte: unklare Verantwortlichkeiten, zirkuläre Abhängigkeiten und mangelnde Wartbarkeit. Jedes Feature bekommt einen eigenen Ordner mit Komponenten, Composables, Store, API-Layer und Typen. Der Shared-Layer enthält nur echte, domänenfreie Abstraktion. Composables übernehmen die Geschäftslogik und halten lokalen Zustand sauber von globalem Pinia-Zustand getrennt. Routing und Lazy-Loading werden pro Feature organisiert.
Der entscheidende Schritt ist nicht die Ordnerstruktur allein, sondern die Durchsetzung der Modul-Grenzen: kein Cross-Feature-Import an internen Komponenten vorbei, kein Zustand in globalen Stores, der einem einzelnen Feature gehört. Automatisierung durch ESLint-Regeln und dependency-cruiser in der CI-Pipeline stellt sicher, dass die **Vue-Architektur** langfristig erhalten bleibt — auch wenn das Team wächst und neue Entwickler hinzukommen, die die ursprüngliche Designentscheidung nicht kennen.
Dateibasierte Vue-Architektur — Das Wichtigste auf einen Blick
Feature-Slicing
Jedes Feature hat einen eigenen Ordner unter features/ mit Komponenten, Composables, Store und API. Keine Cross-Feature-Imports an index.ts vorbei.
Composables
Composables kapseln Geschäftslogik und reaktiven Zustand. Lokaler Zustand per ref(), globaler Zustand über Pinia-Stores — nie vermischen.
Shared-Layer
Nur Abstraktionen, die von mindestens drei Features genutzt werden und keine Feature-Logik enthalten, gehören in shared/.
Grenzen durchsetzen
ESLint-Regeln und dependency-cruiser in CI verhindern, dass Modul-Grenzen über die Zeit erodieren — automatisch, ohne manuelle Code-Reviews.