Skip to content

Screaming Architecture: the key to a scalable frontend

Your frontend project should scream what it does, not what it is built with. Concrete migration examples, framework coexistence with Nuxt, and shared state handling.

7 minArchitecture · Frontend · Vue · Nuxt read in Spanish

The pattern I saw everywhere

Across multiple companies where I worked, I kept running into the same pattern. Teams knew that organizing by features was important, the intention was there. But in practice, the feature ended up scattered across the entire project: components/auth/, services/auth/, store/auth/, views/auth/. The feature name appeared in every technical folder, but never in a single place.

src/
├── components/
│   ├── auth/          # LoginButton.vue, RegisterForm.vue
│   ├── products/      # ProductCard.vue, ProductList.vue
│   └── orders/        # OrderSummary.vue
├── views/
│   ├── auth/          # LoginView.vue, RegisterView.vue
│   ├── products/      # ProductsView.vue
│   └── orders/        # OrdersView.vue
├── services/
│   ├── auth.service.ts
│   ├── products.service.ts
│   └── orders.service.ts
├── store/
│   ├── auth.store.ts
│   ├── products.store.ts
│   └── orders.store.ts
└── utils/

At first glance it looks organized. But when you need to change something in Auth, you touch 4 folders. When a new dev joins, they have to mentally reconstruct which files belong to which feature. And as the app grows, each technical folder becomes a junk drawer with 30+ files.

The problem isn't a lack of organization. It's that the organization screams "technology" instead of screaming "business."

The analogy that explains it all

Imagine a house blueprint. You don't see a "bricks" section, a "cement" section, and a "windows" section. You see "kitchen," "bedroom," "bathroom." The blueprints scream the purpose of each space, not the materials it's built with.

Screaming Architecture seeks the same for your code. Your top-level folder structure should scream the features of your application, not the technologies you use.

What you gain with this approach

  • Instant understanding: you open the project and at a glance you know what the business does.
  • Fast onboarding: new devs understand the project structure in minutes, not days.
  • Localized changes: modifying a feature doesn't force you to touch 4 folders. Everything is in one place.
  • Real scalability: adding a new feature means creating a folder, not inserting files in 6 different places.
  • Easier testing: each module is an isolated unit you can test independently.
  • Safe refactoring: moving or deleting an entire feature means moving or deleting a folder.

Applying Screaming Architecture

The idea is simple: group by features or business domains. Each folder contains everything needed for that functionality.

src/
├── modules/
│   ├── Auth/
│   │   ├── components/   # LoginButton.vue, RegisterForm.vue
│   │   ├── views/        # LoginView.vue, RegisterView.vue
│   │   ├── routes/       # Routes for this module
│   │   ├── store/        # auth.store.ts
│   │   └── services/     # auth.service.ts
│   ├── Products/
│   │   ├── components/   # ProductCard.vue, ProductList.vue
│   │   ├── views/        # ProductsView.vue, ProductDetailView.vue
│   │   ├── store/        # products.store.ts
│   │   └── services/     # products.service.ts
│   └── Orders/
│       ├── components/
│       ├── views/
│       ├── store/
│       └── services/
├── shared/
│   ├── ui/
│   │   ├── components/   # BaseButton.vue, ModalBase.vue
│   │   └── composables/  # useModal.ts, useDarkMode.ts
│   ├── composables/      # useDebounce.ts, useLocalStorage.ts
│   ├── utils/            # formatDate.ts, validateEmail.ts
│   └── assets/           # Global images, base styles
├── layouts/              # DefaultLayout.vue, AuthLayout.vue
├── router/               # General router
├── app.vue
└── main.ts

Not every module needs the same internal structure. Auth may have store/ and services/, but a Landing module might just be components/ and views/. Each module defines what it needs.

From theory to practice: migrating a feature

Let's take the Auth example from the disaggregated structure and see what the migration looks like:

Before (4 folders):

src/components/auth/LoginButton.vue
src/components/auth/RegisterForm.vue
src/views/auth/LoginView.vue
src/views/auth/RegisterView.vue
src/services/auth.service.ts
src/store/auth.store.ts

After (1 folder):

src/modules/Auth/components/LoginButton.vue
src/modules/Auth/components/RegisterForm.vue
src/modules/Auth/views/LoginView.vue
src/modules/Auth/views/RegisterView.vue
src/modules/Auth/services/auth.service.ts
src/modules/Auth/store/auth.store.ts

The imports change, the code doesn't. If your Auth module needs to expose something to the rest of the app (a guard, a composable, the user state), you can create an index.ts that serves as the module's public API:

// src/modules/Auth/index.ts
export { useAuthStore } from './store/auth.store'
export { useCurrentUser } from './composables/useCurrentUser'
export { authGuard } from './routes/guards'

The rest of the app imports from @/modules/Auth, not from internal folders. If you refactor the module's internal structure, external imports don't break.

Framework coexistence

If you use Nuxt, Next, or any framework with folder conventions (pages/, composables/, components/), the obvious question is: how does this coexist with Screaming Architecture?

Framework conventions handle routing and auto-imports. Screaming Architecture handles business logic.

In Nuxt, for example, pages/ defines routes, but the page can be a thin wrapper that imports the actual module:

<!-- pages/auth/login.vue -->
<template>
  <LoginView />
</template>

<script setup lang="ts">
import LoginView from '~/modules/Auth/views/LoginView.vue'
</script>

Nuxt handles routing (/auth/login), and your Auth module handles the logic. pages/ stays thin, just an entry point.

The same applies to composables/. Global composables (auto-imported by Nuxt) go in the framework's conventional folder. Feature-specific composables go inside the module:

composables/              # Auto-imported by Nuxt (global)
├── useTheme.ts
└── useBreakpoint.ts
modules/Auth/composables/ # Auth-specific (manual import)
├── useCurrentUser.ts
└── useAuthRedirect.ts

Cross-cutting concerns: what goes in shared

The most common question when applying this: what happens when two modules need the same thing?

The rule is simple. If something is specific to a feature, it goes in its module. If two or more features use it, it goes in shared/.

shared/ui/ is for generic UI components with no business logic: buttons, modals, inputs. They're the building blocks, not the features.

shared/composables/ is for reusable logic without business state: useDebounce, useLocalStorage, useIntersectionObserver.

shared/utils/ is for pure functions: formatDate, slugify, validateEmail.

What about shared state? If Auth and Orders both need user data, Auth owns the state. Orders imports useCurrentUser from @/modules/Auth. If over time more modules need the same data, you can move that composable to shared/. But don't do it preemptively. Start with the owning module and only move when there's real evidence it's cross-cutting.

When to use it and when not to

This approach works best for medium to large projects with real business logic and multi-dev teams. If your app has 5+ features that grow independently, Screaming Architecture will bring order to your life.

For MVPs, landing pages, or simple CRUDs, it might be overkill. You don't need a module structure for 3 views and a form.

If your project has the potential to grow, adopting it early saves you the painful migration later. And if you already have a large project with a generic structure, you can migrate progressively, one module at a time, starting with the feature that grows the most.

Next time you start a project, think about what the structure will look like when the team grows. If your folders only say components/ and utils/, you know where to start.

New posts in your inbox

I write about software development, tools I'm trying out, and things I learn while building. When I publish, an email lands in your inbox. That's it.

No spam. Unsubscribe anytime.