Project structure and layers
Assignment
A second volunteer has started helping with the app. After a week she writes to Martina Hostettler that she can't find her way around.
As a new volunteer, I want to find everything about one function of the app in one place, so that I can change the requests without knowing the whole app.
The volunteer agrees with her. The files of the requests sit in four folders, and the addresses of the course API are spread across the stores. The acceptance criteria are:
- Everything that belongs to a feature lives in one folder.
- Only one layer knows the addresses of the API and calls
fetch. - A test reports when someone breaks one of these rules.
Learning goals
After this chapter you can
- compare folders by kind with folders by feature,
- name the layers of an SPA and make their dependencies point one way,
- keep shared code apart from the features,
- give a feature an interface,
- check the rules of the structure with a test.
Folders by kind
Section titled “Folders by kind”create-vue sets up a project with folders by the kind of file, components, views for the pages, router and, with Pinia, stores. In a small app you find every file quickly that way. But every folder grows with every function, and things that belong together end up spread out. In the running case app after part 7, the page "My requests" sits in views, the form in components, the store in stores and the rules of a request in src/reservation.js.
Folders by feature
Section titled “Folders by feature”A feature is a function that members also recognize as part of the app, such as the catalog, the favorites or the requests. Every feature gets a folder in src/features with its pages, its own components, its store and its routes. Whoever works on the requests finds almost everything in src/features/reservations.
Both ways are common, and folders by kind are enough for an app with a few pages. Folders by feature pay off once several people work on different functions or a function has many files. Switching is a decision of the kind that chapter 8.3 records.
Layers
Section titled “Layers”Besides its folders, an SPA has layers that take on different jobs. At the top, main.js, App.vue and the router put the app together. Below them are the features with pages, components and stores. They use shared building blocks and the layer for the API, and only that layer talks to the server.
Imports only point down. A shared building block like the card of a thing knows no feature, and the layer for the API knows neither pages nor stores. That way every lower layer can change or be tested without knowing the ones above it.
The layer for the API
Section titled “The layer for the API”In the running case app after part 8, only src/api/http.js knows the addresses and methods of the course API. The stores call functions like loadItems() and don't know that GET /items is behind it.
return { loadItems: () => call('/items'), recordReturn: (id) => call(`/items/${id}/return`, { method: 'POST' }), restoreSession: () => call('/session'), signIn: (number) => call('/session', { method: 'POST', body: { number } }), signOut: () => call('/session', { method: 'DELETE' }), loadReservations: () => call('/reservations'), sendReservation: (request) => call('/reservations', { method: 'POST', body: request }), withdrawReservation: (id) => call(`/reservations/${id}`, { method: 'DELETE' }), }}If the API renames an address, one line in this file changes and no store notices. The next chapter shows how the stores get hold of this layer.
Shared code
Section titled “Shared code”Whatever only one feature needs lives in that feature's folder. As soon as a second feature needs the same thing, it moves to the shared building blocks. In the running case app, the catalog and the favorites show the same card, so ItemCard.vue lives in src/components, the composable useLocalStorage in src/composables and the formatting of dates and amounts in src/utils. The header and the footer belong to the frame of the app and live in src/layout, because they read the stores of several features.
An interface per feature
Section titled “An interface per feature”Each feature decides in an index.js what others may use from it. The page of a thing belongs to the catalog and shows the request form, so the feature of the requests exposes the form, its store and the key for drafts.
// The page of a thing shows the form and the own requests, the header counts themexport { default as ReservationForm } from './ReservationForm.vue'export { useReservationsStore } from './reservationsStore.js'export { draftKey } from './rules.js'Each feature brings its routes in a routes.js, and the router puts them together.
const router = createRouter({ history, routes: [ ...catalogRoutes, ...favoritesRoutes, ...associationRoutes, ...reservationsRoutes, ...authRoutes, ...teamRoutes, // Every path that no other route matches, also over several segments { path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { title: 'Page not found' } }, ],When a file inside a feature changes, only the other files in it are affected, as long as the index.js stays the same.
Checking the rules
Section titled “Checking the rules”A rule that only lives in people's heads breaks with the first change made under time pressure. So a test reads the sources of the running case app and checks the rules of the structure.
describe('Rules of the architecture', () => { it('calls fetch only in the layer for the API', () => { const offenders = sources.filter((file) => !file.startsWith('api/') && /\bfetch\(/.test(read(file))) expect(offenders).toEqual([]) })
it('keeps shared code free of features, layout and router', () => { const shared = sources.filter((file) => /^(api|components|composables|utils)\//.test(file)) const offenders = shared.filter((file) => importsOf(file).some((target) => /^@\/(features|layout|router)\b/.test(target))) expect(offenders).toEqual([]) })The test needs no browser, it reads the files with node:fs. If someone breaks a rule, the test names the file.
In your project
Section titled “In your project”Decide on the folders of your project before the first page exists, by kind or by feature, and record the decision in your README. Let only one layer talk to the server from the start, and a switch to another data source stays small.
In code
Section titled “In code”Work with the running case app after part 8 in the folder case/08-architecture/en of the course repo. Read first and write down what you expect before you change anything.
- Count the folders that hold the files of the requests now. Where were they in
case/07-state/en? - Add a call
fetch('/api/en/items')tosrc/features/catalog/CatalogView.vueand run the tests. Which test speaks up, and what does it name? - In
src/features/catalog/ItemView.vue, import the store from@/features/reservations/reservationsStore.jsinstead of@/features/reservations. What does the test report now? - The course API renames
/reservationsto/requests. Which files do you have to change?
Exercises
Section titled “Exercises”The exercises sort a small app with a catalog and favorites by feature. You'll find the project in the folder exercises/08-architecture/structure/en of the course repo. Copy the folder, install the packages and start the tests.
npm installnpm testExercise Getting started
Sort the files by feature. CatalogView.vue goes to src/features/catalog, FavoritesView.vue and the favorites store as favoritesStore.js go to src/features/favorites. AppHeader.vue and ItemCard.vue stay in src/components, and the folders src/views and src/stores disappear. Adjust the imports in the router and the header.
Solution
src/ components/AppHeader.vue, ItemCard.vue features/catalog/CatalogView.vue features/favorites/FavoritesView.vue, favoritesStore.js router/index.jsThe solution in the course repo already contains going further and the challenge, so its router reads the routes of the features.
Exercise Going further
Both pages load the catalog themselves with fetch. Create the layer for the API in src/api/items.js with a function fetchItems(), and a store useCatalogStore in src/features/catalog/catalogStore.js that loads the catalog with it, only once for both pages. Outside of src/api, nobody calls fetch anymore.
Solution
// The only layer that talks to the server. The address comes from .env.example or .env.local.const API_URL = import.meta.env.VITE_API_URL ?? '/api/en'
export async function fetchItems() { const response = await fetch(`${API_URL}/items`) if (!response.ok) throw new Error(`The course API answers with the status ${response.status}.`) return response.json()}import { defineStore } from 'pinia'import { ref } from 'vue'
import { fetchItems } from '@/api/items.js'
export const useCatalogStore = defineStore('catalog', () => { const items = ref([]) // idle before the first load, then loading, ready or error const status = ref('idle')
// Both pages call load, the catalog comes from the API only once async function load() { if (status.value === 'loading' || status.value === 'ready') return status.value = 'loading' try { items.value = await fetchItems() status.value = 'ready' } catch { status.value = 'error' } }
return { items, status, load }})The action load returns right away when the catalog is already loading or there. The favorites page calls it anyway, in case someone opens that page directly.
Exercise Challenge
Give both features an index.js through which others get their store, and a routes.js with their routes. The router puts the routes together, and no feature reaches deeper into another one.
Solution
// What other features may use from the catalogexport { useCatalogStore } from './catalogStore.js'import { createRouter, createWebHistory } from 'vue-router'
import { catalogRoutes } from '@/features/catalog/routes.js'import { favoritesRoutes } from '@/features/favorites/routes.js'
// Every feature brings its routes, the router only puts them togetherexport default createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [...catalogRoutes, ...favoritesRoutes],})The favorites page gets the catalog through @/features/catalog, the header gets the favorites through @/features/favorites. Inside a feature, the files import each other directly with ./.
Common mistakes
Section titled “Common mistakes”| Mistake | What helps |
|---|---|
| A file fits no feature and ends up in a folder for miscellaneous things. | If it belongs to exactly one feature, it goes there. If several need it, it belongs to the shared building blocks. |
| Two features reach deep into each other's files. | Go through the index.js and check whether a part belongs to the shared building blocks. |
A component in src/components reads a store. | It gets its data as props, and the page of the feature knows the store. |
| After moving files, imports break all over the app. | Import with the alias @, and reach other features only through their index.js. |
An AI assistant puts every new file into src/components. | Tell it how the project is organized, and check where the file belongs. |
Self-check
Section titled “Self-check”Self-check
- How do folders by kind differ from folders by feature?
- Which layers does the running case app have, and which way do its imports point?
- What is the
index.jsof a feature for? - Why does a test check the rules of the structure?
Answers
- By kind, files of the same kind sit together, such as all pages. By feature, everything that belongs to one function sits together.
- App and router, features, shared building blocks and the layer for the API. Imports only point down.
- It defines what other features may use. Whatever isn't in it may change without others noticing.
- A rule that only someone remembers breaks easily. The test reports every violation together with the file.
Assignment
The new volunteer finds her way around now. Martina Hostettler wants to show the app at the general meeting, and no course API runs there. The volunteer also wants to sort by availability, and the new volunteer asks why the card of a thing knows no store. The answers come from the next chapter on design patterns.
Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.