{}const=>[]async()letfn</>var
DevelopmentWeb

How to build a front-end architecture so that the project does not die in a year

Why do some projects turn into spaghetti code after six months, while others live and develop for years? We analyze the proven principles of building a front-end architecture: from the folder structure to the abstraction layers. Learn how to create code that is easy to scale, test, and maintain.

К

Kodik

Author

9 min read

Imagine: you are starting a new project. Enthusiasm is at its peak, and the code is written quickly. After a couple of months, you've already forgotten why you need that strange function in utils.js. Six months later, adding a new feature turns into a quest. A year later, the project turns into a swamp, where every change can break everything.

Sounds familiar? This is a classic result of the lack of a well-thought-out architecture. Let's figure out how to build frontend projects that will live and evolve for years.

Why do projects die?

Before we talk about solutions, let's understand the main reasons for the death of projects:

Spaghetti code. Everything is connected to everything. A change in one place breaks the other three components.

Lack of structure. Files are scattered chaotically. Finding the right component is an archaeological expedition.

Duplication of logic. The same functionality is implemented in five different places in five different ways.

No documentation. Even you yourself don't remember how your code works in a month.

Technical debt. "I'll fix it later" turns into "I'll never fix it."

🔥 100,000+ students already with us

Tired of reading theory?
Time to code!

Kodik — an app where you learn to code through practice. AI mentor, interactive lessons, real projects.

🤖 AI 24/7
🎓 Certificates
💰 Free
🚀 Start learning
Joined today

Foundation: the correct folder structure

Good architecture begins with file organization. Here is a proven structure for most projects:

src/
├── components/          # Reusable components
│   ├── ui/             # Basic UI elements (buttons, inputs)
│   ├── layout/         # Layout components (header, footer)
│   └── features/       # Business components
├── pages/              # Application pages
├── services/           # Working with the API
├── store/              # Global state (Vuex, Redux, Pinia)
├── utils/              # Auxiliary functions
├── hooks/              # Custom hooks (for React)
├── composables/        # Composite functions (for Vue)
├── types/              # TypeScript types
├── constants/          # Application constants
└── assets/             # Static resources

Principle: each folder is responsible for one area of responsibility. When you need a UI component, you go to components/ui. If you need a function to work with the API, go to services.

Principle of sole responsibility

Each module should do one thing, but do it well.

Bad:

// UserCard.js - does everything at once
function UserCard({ userId }) {
  const [user, setUser] = useState(null);
  
  // Data collection
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser);
  }, [userId]);
  
  // Validation
  const isValid = user?.email && user?.name;
  
  // Formatting
  const formattedDate = new Date(user?.created).toLocaleDateString();
  
  // And more rendering...
  return <div>...</div>;
}

Good:

// services/userService.js
export const fetchUser = async (userId) => {
  const response = await fetch(`/api/users/${userId}`);
  return response.json();
};

// utils/validation.js
export const validateUser = (user) => {
  return user?.email && user?.name;
};

// utils/dateFormatter.js
export const formatDate = (date) => {
  return new Date(date).toLocaleDateString();
};

// components/UserCard.js - display only
function UserCard({ userId }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);
  
  if (!validateUser(user)) return null;
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <span>{formatDate(user.created)}</span>
    </div>
  );
}

Now each function is tested separately, used in different places, and easily modified.

Abstraction layers: divide and conquer

Good architecture is built in layers, like an onion:

1. Data Layer

Responsible for receiving and sending data. All API requests live here.

// services/api/userApi.js
const API_BASE = 'https://api.example.com';

export const userApi = {
  getUser: (id) => fetch(`${API_BASE}/users/${id}`).then(r => r.json()),
  updateUser: (id, data) => fetch(`${API_BASE}/users/${id}`, {
    method: 'PUT',
    body: JSON.stringify(data)
  }),
  deleteUser: (id) => fetch(`${API_BASE}/users/${id}`, { method: 'DELETE' })
};

2. Business Logic Layer

Processes data, applies business logic rules.

// services/userService.js
import { userApi } from './api/userApi';

export const userService = {
  async getActiveUsers() {
    const users = await userApi.getUsers();
    return users.filter(user => user.isActive);
  },
  
  async promoteToAdmin(userId) {
    const user = await userApi.getUser(userId);
    if (!user.email.endsWith('@company.com')) {
      throw new Error('Only company emails can be admins');
    }
    return userApi.updateUser(userId, { role: 'admin' });
  }
};

3. State Layer

Manages the global state of the application.

// store/userStore.js (Pinia/Vue)
export const useUserStore = defineStore('user', {
  state: () => ({
    currentUser: null,
    users: []
  }),
  
  actions: {
    async loadUser(id) {
      this.currentUser = await userService.getUser(id);
    }
  }
});

4. Presentation Layer

Components that display data to the user.

// components/UserProfile.vue
<script setup>
import { useUserStore } from '@/store/userStore';

const userStore = useUserStore();
const { currentUser } = storeToRefs(userStore);

onMounted(() => {
  userStore.loadUser(route.params.id);
});
</script>

<template>
  <div v-if="currentUser">
    <h1>{{ currentUser.name }}</h1>
    <p>{{ currentUser.email }}</p>
  </div>
</template>

Golden rule: upper layers can use lower ones, but not vice versa. Components use store, store uses services, but services never import components.

Composition instead of inheritance

In the modern front end, composition wins over inheritance. Instead of giant base classes, create small reusable functions.

Example with React hooks:

// hooks/useLocalStorage.js
export function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });
  
  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);
  
  return [value, setValue];
}

// hooks/useDebounce.js
export function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);
  
  useEffect(() => {
    const handler = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(handler);
  }, [value, delay]);
  
  return debouncedValue;
}

// Use
function SearchComponent() {
  const [query, setQuery] = useLocalStorage('searchQuery', '');
  const debouncedQuery = useDebounce(query, 500);
  
  // Now you have a search with debounce and saving in localStorage
  useEffect(() => {
    if (debouncedQuery) {
      searchApi(debouncedQuery);
    }
  }, [debouncedQuery]);
}

Typification is your best friend

TypeScript may seem superfluous for beginners, but it will save the project from many problems.

// types/user.ts
export interface User {
  id: number;
  name: string;
  email: string;
  role: 'user' | 'admin' | 'moderator';
  createdAt: Date;
}

export interface ApiResponse<T> {
  data: T;
  error?: string;
  status: number;
}

// services/userService.ts
export async function getUser(id: number): Promise<ApiResponse<User>> {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

Now the IDE will prompt you for available fields, and TypeScript will catch errors at the development stage, not in production.

Configuration and constants

Do not scatter magic numbers and strings around the code. Collect them in one place.

// constants/config.js
export const API_CONFIG = {
  BASE_URL: process.env.VITE_API_URL || 'https://api.example.com',
  TIMEOUT: 5000,
  RETRY_ATTEMPTS: 3
};

export const UI_CONSTANTS = {
  ITEMS_PER_PAGE: 20,
  DEBOUNCE_DELAY: 300,
  TOAST_DURATION: 3000
};

export const ROUTES = {
  HOME: '/',
  PROFILE: '/profile',
  SETTINGS: '/settings'
};

When you need to change the number of items on the page, you will know where to do it.

Error handling

A systematic approach to errors is a sign of a mature architecture.

// utils/errorHandler.js
export class ApiError extends Error {
  constructor(message, status, data) {
    super(message);
    this.status = status;
    this.data = data;
  }
}

export async function handleApiCall(apiFunction) {
  try {
    return await apiFunction();
  } catch (error) {
    if (error instanceof ApiError) {
      // We show the user a clear message
      toast.error(error.message);
      
      // Logging for developers
      console.error('API Error:', error.status, error.data);
    } else {
      // Unexpected error
      toast.error('Something went wrong. Please try again later.');
      console.error('Unexpected error:', error);
    }
    throw error;
  }
}

// Use
async function loadUserData(userId) {
  await handleApiCall(async () => {
    const user = await userApi.getUser(userId);
    if (!user) {
      throw new ApiError('User not found', 404);
    }
    return user;
  });
}

Document architectural solutions

Create a file ARCHITECTURE.md in the project root:

# Project architecture

# # Structure
- `/components` - переиспользуемые компоненты
- `/pages` - страницы приложения
- `/services` - бизнес-логика и API

# # Agreements
- Компоненты именуются в PascalCase
- Утилиты и сервисы в camelCase
- Константы в SCREAMING_SNAKE_CASE

# # Layers
1. API Layer (services/api/)
2. Business Logic (services/)
3. State Management (store/)
4. UI Components (components/)

# # Important decisions
- Используем Pinia для состояния
- Axios для HTTP запросов
- День.js для работы с датами

Code review and linting

Configure ESLint and Prettier at once. This will prevent 90% of code readability problems.

// .eslintrc.js
module.exports = {
  rules: {
    'no-console': 'warn',
    'no-unused-vars': 'error',
    'complexity': ['error', 10], // Warns about complex functions
    'max-lines-per-function': ['warn', 50]
  }
};

Architecture testing

Good architecture is easy to test.

// userService.test.js
import { userService } from './userService';
import { userApi } from './api/userApi';

jest.mock('./api/userApi');

test('promoteToAdmin rejects external emails', async () => {
  userApi.getUser.mockResolvedValue({
    id: 1,
    email: 'external@gmail.com'
  });
  
  await expect(
    userService.promoteToAdmin(1)
  ).rejects.toThrow('Only company emails');
});

If your functions are difficult to test, it is a signal that the architecture is flawed.

Scaling: Feature-Based Structure

When the project grows, group the code by features, not by file types:

src/
├── features/
│   ├── auth/
│   │   ├── components/
│   │   ├── services/
│   │   ├── store/
│   │   └── types/
│   ├── products/
│   │   ├── components/
│   │   ├── services/
│   │   └── store/
│   └── cart/
│       ├── components/
│       └── store/
└── shared/              # Common components and utilities

Now everything related to authorization is in one folder. Easy to find, easy to delete, easy to transfer to another developer.

Common mistakes of beginners!

Premature optimization

You don't need to build an architecture for a million users right away. Start with a simple but scalable solution.

Excessive abstraction

If you only have one button, you don't need to create a system of five base classes for it.

Ignoring conventions

Use the common practices of your framework. Don't reinvent the wheel.

Lack of refactoring

Set aside time to improve the architecture. Technical debt accumulates unnoticed.

Practical advice.

Start with README. Describe how the project works before writing the code. This will make you think about architecture.

Refactor regularly. Set aside an hour a week to improve existing code.

Learn from others. Study open-source projects, see how they are organized.

Don't be afraid to redo. If the structure doesn't work, it's better to fix it now than to live with it for years.

Tools to help.

  • ESLint/Prettier — automatic code formatting

  • Husky — code check before commit

  • TypeScript - standardization for large projects

  • Storybook — development of components in isolation

  • Jest/Vitest — logic testing

Conclusion

Good architecture is not perfect code the first time. It is a systematic approach that allows the project to evolve. Start with a simple but logical structure. Follow the principle of single responsibility. Document important decisions. And most importantly, regularly review and improve your code.

In a year, you will thank yourself for every minute you invested in architecture. Your project will live, develop and bring joy, and will not turn into a spaghetti ball that is scary to touch.

This and much more can be learned in Codice — analyze everything in detail and consolidate it with practice and tasks. We teach not just to write code, but to build the right, scalable applications that will last for years.

And if you need support, we already have more than 2000 like-minded people in an active Telegram channel, where you can ask any question, discuss architectural solutions and get a review of your code from experienced developers.

Join the community where real professionals grow! 🚀

🎯Stop procrastinating

Liked the article?
Time to practice!

In Kodik, you don't just read — you write code immediately. Theory + practice = real skills.

Instant practice
🧠AI explains code
🏆Certificate

No registration • No card