DEV Community

Cover image for Modular Frontend Architecture
İbrahim Turan
İbrahim Turan

Posted on

Modular Frontend Architecture

If you have a small frontend project, putting the same type of things in the same place is a good idea. You can think of it like money inside a cash register. Every type of bill is stacked neatly on top of each other.

Let's assume you need to duplicate every holder inside the cash register. Now you have a double $20 stack and a double $10 stack, and the cash in these stacks cannot mix. So, when a customer gives you $20, you need to put it into that specific stack of $20 bills, and you need to give change from that same related stack. The second stack of $10 bills and the same cup of cents work the exact same way.

There is another option: splitting the cash register in half and mirroring it. That way, when you take a $20 bill from one side of the register, you skip the other part as a whole. Let's make it a little bit harder and assume $50 bills are shared. What you can do is add a second floor to the cash register and put all of the shared bills there as a single stack, because it doesn’t matter where you get them from or put them.

This is the exact same idea we use in modular frontend architecture. When you create a project, everything is traditionally placed based on its type: tests inside the tests folder, components inside the components folder, and so on. But as the project gets bigger and bigger, you will have a hard time keeping track of which component is related to what, or which store holds what type of data. This slows you down because you have to track everything you change just to ensure you didn't break another area of the project. This eventually causes a lot of hidden bugs and undesired behavior inside the system.

cash register

If the cash register explains how we organize files, computer hardware explains how they communicate. 

You can think of modules like computer parts inside a computer case. A computer is a very complex thing, but it is built out of independent parts like a GPU, CPU, and RAM sticks.

Every part is completely different internally and handles its own specific job. However, they are fully encapsulated. The motherboard doesn't need to deal with the raw circuitry or internal chips of the graphics card; it only interacts with it through a standardized interface, like a PCIe slot.

The motherboard acts like a page or the global scope. It doesn't need to know how the graphics card renders pixels inside, it only cares that it fits the slot and follows the communication rules. Because of this encapsulation, you can change your graphics card with a new one easily without changing the rest of the computer. 

This is the same way frontend modules should behave. The internal logic can be complex and unique, but the way it communicates with the rest of the project must go through a single, strict gateway.

inside of the computer

Why Modular Architecture?

When projects get big enough, they eventually become monorepo or micro-frontend projects. Converting a project to a monorepo without understanding boundaries and relationships is incredibly painful. You can imagine modular architecture as a perfect middle ground between these approaches.

Also, we frequently develop projects using AI assistance nowadays. AI is limited by its context window, and you cannot manage the whole context window effectively if you don’t know the entire project well enough. Without strict boundaries, the AI will pull references from completely unrelated areas of the project. If your project doesn’t have enough automated guardrails, you will end up using deprecated components or outdated logic. If nobody catches this during review, you will have a huge mess in a very short period of time.

Modules help you manage the AI context. The AI will have a much better understanding of the code and what you actually want to achieve. It also cuts costs; when the context window gets too big, the AI tends to become less effective and more expensive.

Lastly, updating the project becomes less painful, and handling bugs takes much less time. You can easily find and track bugs on platforms like Sentry because of the specific naming conventions used inside the modules.

Project Structure

messy room
Modules are essentially sub-programs of the project. They contain most of the same file structure that the global scope has. However, some folders—like plugins, layouts, pages, and services—are only available in the global scope.

I purposely put services in the global scope because putting them inside individual modules causes a lot of code duplication, type management issues, and data management problems. It can be a little harder to find and use services on very big projects, but I still don’t recommend putting them inside modules, even if they are purely related to a single module.

Making exceptions to your structure introduces a lot of entropy into a project because AI does not work deterministically and will put things in different places every time. It also causes team communication problems. If you decide on a specific architecture, you must be entirely consistent about it.

I also prefer to put i18n translations inside the modules. If your project requires i18n, you can split it between the global scope and individual modules.

├── assets/                  # Global static assets (images, global styles, fonts)
├── components/              # Global reusable UI components (atomic/base components)
├── constants/               # Global application constants and enums
├── layouts/                 # Page layouts (e.g., Default, Auth, Dashboard)
├── libs/                    # Global utility libraries and helper functions
├── plugins/                 # Third-party plugin configurations (e.g., i18n, UI libraries)
├── services/                # Global API clients and data-fetching layer
├── store/                   # Global state management (e.g., Pinia, Redux)
├── types/                   # Global TypeScript types and interfaces
├── tests/                   # Global integration, E2E, or orchestration tests
│
├── docs/                    # Project documentation
│   ├── ADR/                 # Architectural Decision Records
│   │   ├── _superseded/     # Outdated or replaced ADRs
│   │   └── [ADR-NO]_[DATE]-[title].md
│   ├── changelog/           # Version history
│   │   ├── UNRELEASED.md    # Unreleased changes for the next version
│   │   └── [VERSION].md     # Released version logs
│   ├── how-to/              # Guides for developers (e.g., deployment, local setup)
│   ├── architecture.md      # High-level system architecture overview
│   └── setup.md             # Local environment onboarding guide
│
├── modules/                 # Feature-driven domain modules
│   └── [module-name]/       # Encapsulated feature module (e.g., 'auth', 'billing')
│       ├── assets/          # Module-specific static assets
│       ├── components/      # Module-specific UI components
│       │   ├── [ModuleName][ComponentName].vue
│       │   └── [ModuleName][ComponentName][SubComponent].vue
│       ├── constants/       # Module-specific constants
│       ├── docs/            # Module-specific business logic documentation
│       ├── hooks/           # Module-specific hooks or composables (e.g., use[ModuleName][HookName].ts)
│       ├── libs/            # Module-specific utility wrappers
│       ├── store/           # Module-specific state slices (e.g., [module-name]-[store-name].store.ts)
│       ├── tests/           # Unit tests isolated to this module
│       │   └── [component|lib|hook].test.ts
│       ├── types/           # Module-specific TypeScript types
│       └── index.ts         # Module public API (exports what the rest of the app can use)
│
├── views/                   # Application pages/routes (maps to router config)
├── AGENTS.md                # AI Agent / LLM context instructions and guardrails
├── main.ts                  # Application entry point
└── README.md                # Project landing page

Enter fullscreen mode Exit fullscreen mode

Global Scope

The global scope holds shared logic like hooks/composables, utility libraries (libs), and plugins. Components inside the global scope should strictly be dumb components. I prefer putting core business logic inside pages and hooks; otherwise, that logic belongs entirely inside a module.

Modules

Modules are encapsulated, independent sub-forms of the project. They can have almost any folder feature the global scope has when needed, excluding plugins, layouts, and public folders.

Modules can also just represent shared logic. For example, table configurations could live inside a table module, even if that module doesn't contain any UI components and only consists of pure implementation and configuration files.

You should aim to have a few "perfect" modules in your codebase that you can feed to the AI as a reference. That way, the AI will produce much better, aligned code for the rest of your project.

Module Rules

  • The Public API: Modules must have a root file (usually named index.ts) at their root. This file must explicitly expose every API the module offers (components, hooks, constants, utilities) to the outside world.
  • Component Naming: Module components should always use the module name as a prefix. This prevents naming collisions when they are used inside pages or the global scope.
  • Page Composition: Pages can contain one or multiple modules. Pages can contain logic that alters module behavior, or they can compose multiple modules together to build different layouts and workflows. They manage this composition to achieve different user flows. While these page-level changes affect the module's behavior via props, the module itself must remain independent. A module should never directly trigger a change in another module; that orchestration is the sole responsibility of the page using them.
  • No Partial Exposures: Modules cannot expose partial components or fragmented logic to the outside to be gathered and re-assembled by the user. For example, you shouldn't expose raw form fields, partial forms, and separate form-handling logic externally just to build multiple different forms across the app. This causes massive encapsulation issues, and the module loses its independence. When you update a module, you should only need to update the module internals and its public API. A module should never change the flow of the outside program; it should only change the internal behavior of the module itself.
  • Communication: Modules handle their internal logic and do not leak it outside. When a module uses components, it is strictly responsible for communicating with the outside world through standardized props and events.
  • Dependencies: A module can depend on another module, but it cannot use internal parts of another module directly. You must either import the other module as a whole sub-module through its public API, or move the shared pieces into the global scope so both modules can use them safely.

Documenting the Project

some documents on the table
Documenting and testing modules is vital. Modules left undocumented usually lead to logic duplication because other developers don't know they exist. Modules without tests cause major issues when you eventually need to rewrite or upgrade them.

The overall project architecture, global usages, page creation steps, layout logic, authentication, permission management (e.g., how to check page or action permissions), error handling, feedback handling, form validation, and global UI components should all be thoroughly documented in the global scope.

You should write specific "how-to" documents regarding module creation, module composition, module usage, and page creation. This will explicitly teach your AI assistant how to create and use modules correctly within your codebase.

ADR and Change Log System

An Architectural Decision Record (ADR) system is incredibly helpful when working in a team on parallel tasks. An ADR document serves as an official agreement that you can reference during PR reviews and feature implementation. When you need to refactor, deprecate, or develop a major new feature, write an ADR. This aligns the team so no one is caught off guard by a massive pull request.

For reference, I highly recommend checking out the Shopware ADR list on their public GitHub repository to see how an engineering team uses them effectively.

You should never delete old ADR documents. Use an incremental document system where new decisions simply supersede the old ones. Without these historical documents, teams often make the same mistakes or attempt the same failed implementations multiple times. If you delete the ADR explaining why a library or pattern was removed due to side effects, future developers might introduce it all over again.

Additionally, keeping a changelog helps the AI understand the project's history and timeline. I suggest tracking unreleased changes inside an UNRELEASED.md file, which must be updated before a PR is merged. The structure should be a simple bulleted list formatted as follows: * [bug|feature|performance|chore]: explain the change or action

ADR Template

---
title: "{{ title }}"
date: {{ date }}
area: {{ area }}
tags: [{{ tags }}]
---

## Context

## Decision

## Consequences

Enter fullscreen mode Exit fullscreen mode

Documenting a Module

Module-specific documentation should live directly inside that module's docs/ folder. This includes local "how-to" guides, updates to module behavior, architectural quirks, or a specific domain glossary unique to that feature.

Ensuring Project Quality

If you work with AI, you must enforce high code quality because AI will ultimately copy the existing quality of the project. The hardest part of working with an LLM is raising the code quality ceiling, as AI naturally generates code that mimics the majority of your codebase.

Because of this, you should implement strict validation and linting rules before you start using AI heavily or converting your project to a modular architecture. Every limitation or architectural restriction should be enforced via an ESLint rule or custom validation scripts. AI can read documentation, but it will still make mistakes if automated rules are missing. For example, if you deprecate a UI library, you need an ESLint rule to explicitly restrict its imports.

Enforce the strictest code styling possible. There should be exactly one way of doing things, validated automatically. If you prefer arrow functions, enforce them everywhere. If you prefer object parameters, write an ESLint rule for it. If you use kebab-case file naming, use a custom validator script alongside ESLint to check it.

Lastly, never deploy dead code into your main branch. Use tools and scripts to detect unused variables, functions, components, and files, and delete them immediately. If you ever need them back, they are safely stored in your Git history. Don't carry that maintenance burden in your main repository.

I hope this article helps you improve your frontend architecture. In my next article, I will share concrete module examples, implementation details, and exactly how I prompt and guide AI within this architecture to compose modules seamlessly. Thank you for reading, and I'll see you in the next one!

Top comments (0)