When we build interfaces or components, we must decide how to organize our code. Do we group files by type (e.g., all CSS in one folder, all JavaScript in another) or by feature (e.g., all files related to a specific component together), or do we bundle everything into a single module? This decision can impact the maintainability and scalability of our codebase.
As I've researched this topic, I've come across several resources that discuss different approaches to organizing code and related concepts.
This post explores how to build components by analyzing different approaches to code organization and the concepts of coupling, cohesion, and cognitive load, along with how they apply to component packaging.
How we organize our code can affect how we understand, maintain, and scale our applications. Different approaches can lead to varying levels of cognitive load, coupling, and cohesion, which in turn impact developer productivity and code quality.
Before looking at the approaches themselves, it helps to define the criteria we will use to compare them.
Cognitive load is how much a developer needs to think in order to complete a task.
When reading code, you put things like values of variables, control flow logic and call sequences into your head. The average person can hold roughly four such chunks in working memory. Once the cognitive load reaches this threshold, it becomes much harder to understand things.
Let's say we have been asked to make some fixes to a completely unfamiliar project. We were told that a really smart developer had contributed to it. Lots of cool architectures, fancy libraries and trendy technologies were used. In other words, the author had created a high cognitive load for us.
Imagine trying to understand what this does: you need to hold multiple concerns in mind at once.
constprocessData=(d, c, v, r, s, m)=>{const a = d.map((x)=> x *2).filter((x)=> x >10);const b = a.reduce((acc, x)=> acc + x,0);const e = c.find((x)=> x.id === v);if(!e || b > r)return{ error: m };const p =s(b);return{ data: p, cached: c.length >0};};
Problems:
Single-letter variable names require you to track what each holds
Multiple responsibilities mixed together
No clear intent or separation of concerns
Low cognitive load example:
// Immediately clear what each step doesconstprocessUserScores=(scores, cachedCategories, categoryId, maxScore, scorer, errorMsg)=>{const validScores =filterAndDoubleScores(scores);const totalScore =sumScores(validScores);const category =findCategory(cachedCategories, categoryId);if(!category || totalScore > maxScore){return{ error: errorMsg };}const processedScore =scorer(totalScore);return{ data: processedScore, cached:hasCachedData(cachedCategories)};};constfilterAndDoubleScores=(scores)=> scores.map((s)=> s *2).filter((s)=> s >10);constsumScores=(scores)=> scores.reduce((sum, s)=> sum + s,0);constfindCategory=(categories, id)=> categories.find((c)=> c.id === id);consthasCachedData=(categories)=> categories.length >0;
Benefits:
Each function has one purpose
Names clearly describe intent
Easier to test and reuse
Developers can understand a section without holding all details in memory
Coupling refers to the degree of interdependence between software modules. High coupling means that modules are closely connected and changes in one module may affect other modules. Low coupling means that modules are independent, and changes in one module have little impact on other modules.
Coupling
Cohesion refers to the degree to which elements within a module work together to fulfill a single, well-defined purpose. High cohesion means that elements are closely related and focused on a single purpose, while low cohesion means that elements are loosely related and serve multiple purposes.
Changing logging, database, or crypto layer affects this class.
Hard to test because it depends on real DB and config.
Cannot reuse the validation or hashing logic elsewhere.
Low coupling, high cohesion example:
classUserService{constructor(private userRepository: UserRepository,// Abstraction, not concrete DBprivate passwordHasher: PasswordHasher,// Abstraction, not bcrypt){}asynccreateUser(email:string, password:string){// Focused: only orchestrates user creationconst hashedPassword =awaitthis.passwordHasher.hash(password);returnthis.userRepository.create({ email, passwordHash: hashedPassword });}}// Email validation is a separate concernconst isValidEmail =(email:string):boolean=> email.includes("@");
Now:
UserService is independent of logging, config, and auditing
Each piece is testable in isolation (mock the repository and hasher)
Changes to DB or crypto don't break UserService
Validation and hashing logic can be reused elsewhere
Notice in the second example:
Cohesion: Each class has a single, well-defined purpose
Decoupling: Dependencies are abstractions (interfaces), not concrete implementations
Result: Easy to test, modify, and reuse
With those criteria in mind, we can now look at the organization strategies themselves.
The most basic approach is not to have any structure at all. All files are in a single directory, and there are no conventions for naming or grouping.
All files are in one place. This works best for small projects or prototypes, but will quickly become unmanageable.
Once a project grows past that stage, teams usually introduce structure by grouping code according to technical role. That is where horizontal layering usually enters the picture.
This is what we often see in traditional MVC (Model-View-Controller) or layered architectures. Code is organized into layers based on technical concerns (e.g., all database code in one directory, all components in their own directory, all utility code in another).
Files are grouped by types (all components together, all services together, etc.). This can lead to "dependency hell" where changes in one layer affect multiple other layers.
The diagram below shows a horizontal layering structure for a Vue3 project:
This is often the first meaningful improvement over no structure, but it still optimizes for technical categories more than for the way developers actually change features. The next step is to organize around slices of behavior instead of shared file types.
This helps when high cohesion, easy navigation, and low coupling between features are priorities.
If you keep pushing that idea further, you can make the slice even smaller. Instead of grouping everything for a feature together, you can group everything for a single request or endpoint together.
A highly granular version of Vertical Slices often used in API development.
Each API endpoint is its own class, handling its own request, response, and logic, removing the need for a central "Controller" layer.
This helps when endpoint behavior needs to be isolated, independently testable, and easy to change without side effects.
Example of a single REPR endpoint:
// features/auth/register-user.tsexportclassRegisterUserEndpoint{constructor(private db: Database,private emailService: EmailService,private passwordHasher: PasswordHasher){}asynchandle(request:{ email:string; password:string}):Promise<{ status:number; body:{ id:string}}>{// Validateif(!request.email.includes("@"))return{
status:400,
body:{
error:"Invalid email"}};// Check if existsconst existing =awaitthis.db.users.findByEmail(request.email);if(existing)return{
status:409,
body:{
error:"Email already registered"}};// Hash passwordconst hash =awaitthis.passwordHasher.hash(request.password);// Create userconst user =awaitthis.db.users.create({ email: request.email, passwordHash: hash });// Send confirmationawaitthis.emailService.sendConfirmation(request.email);return{ status:201, body:{ id: user.id }};}}// Each endpoint is self-contained; no dispatcher or central router needed.// Add a new endpoint? Create a new REPR class. Modify one? Only that class changes.
REPR is useful when the unit of change is extremely small, especially in backend or API-heavy systems. On the frontend, though, teams often need a broader structure that still centers features while separating UI, state, and integration concerns.
FSD is a specialized frontend architecture, particularly popular in React/Next.js, that organizes code into layers based on abstraction levels, then by features.
This helps when frontend teams need clear boundaries between UI, state, API integration, and local utilities inside each feature.
src/
├── features/ # Layer
│ └── add-to-cart/ # Slice
│ ├── ui/ # Segment: React/Vue components
│ ├── model/ # Segment: Business logic, state (Redux/Zustand), types
│ ├── api/ # Segment: Fetching/API requests
│ ├── lib/ # Segment: Slice-specific helpers
│ └── index.tsx # Public API (entry point for the slice)
FSD keeps the focus on features, but it is still primarily a frontend-oriented way to structure work. If the goal shifts from frontend features to business domains that span multiple use cases, the next model broadens the organizing principle again.
DDD moves the center of gravity from UI features to business capabilities. From there, the next question is not just how to group code, but how to control dependency direction so that business rules remain insulated from frameworks and infrastructure.
While often considered a strict form of layering, these are alternatives to naive vertical layering. They focus on domain-centric design.
In this design system, the core business logic is at the center, surrounded by layers for application services, infrastructure, and UI. Infrastructure depends on the domain, not vice versa.
This helps when preserving core business rules from framework and infrastructure churn is more important than optimizing for delivery speed in a single layer.
What this shows: this file is a port (an interface contract) in the domain layer. It defines what the business needs (existsByEmail, save) without saying how data is stored.
What this shows: the use case depends on the domain contract, not on a concrete database. This keeps application logic focused on rules and flow (check existing user, then save) and makes it easy to test by mocking UserRepository.
What this shows: infrastructure is an adapter that implements the domain contract using a specific technology (Postgres). If you switch to another storage engine, you replace this adapter, not the use case.
What this shows: presentation handles transport concerns (HTTP request/response) and delegates business behavior to the application layer. The controller does not contain business rules; it just maps input/output.
Notice the direction: business logic (domain + application) is stable and reusable, while infrastructure and UI can change with minimal impact.
There is no single "best" way to organize components. The right structure depends on what you are optimizing for in your project right now.
If your goal is speed on a small codebase, a simple structure may be enough. As the system grows, the cost of understanding and changing code grows too, and that is where cognitive load, coupling, and cohesion become useful decision criteria.
Across the models in this post, the direction is clear:
Horizontal layers improve basic order but can spread feature changes across many folders.
Vertical slices and REPR localize behavior and make change safer.
FSD adds clearer frontend boundaries for UI, model, and API concerns.
DDD aligns code with business language and domain ownership.
Clean Architecture emphasizes dependency direction to protect core business rules.
In practice, architecture is not a one-time choice. Teams often start simple, then evolve toward stronger boundaries as complexity increases. The key is to pick the smallest structure that reduces cognitive load today while leaving room to grow tomorrow.