Home / Articles / Ten Architecture Habits That Keep Frontend Codebases Maintainable for Years

This article is published in English.

Ten Architecture Habits That Keep Frontend Codebases Maintainable for Years

Explains structural habits like optimizing for deletability, explicit data flow, and isolating business logic that help codebases stay maintainable through years of change.

3870 words

Every frontend codebase that survives long enough eventually splits into two distinct regions.

The first can be called the Danger Zone.

It's a messy mix of state managers, patched-over lifecycle hooks, clever but opaque global abstractions, half-finished experiments, and helper functions written years ago that nobody can fully explain anymore.

No one wants to go near it.

When a new feature request lands in that part of the app, the team doesn't size the ticket based on how hard the actual work is.

They size it based on how risky it feels to touch that code.

A change that should take two days turns into a two-week effort because everyone knows the bulk of the time will go into regression testing rather than building.

Then there's the other region.

Call it the Stable Foundation.

These are the modules built years earlier that have quietly outlasted several framework migrations, redesigns, shifts in product direction, and changes in engineering leadership.

They almost never cause outages.

There's nothing particularly clever about how they're written.

And when someone new joins the team, they can open one of these files, understand what it does without needing a walkthrough, and ship a first pull request within a day or two.

That's the part worth paying attention to.

The code that lasts isn't usually the most advanced code in the system.

It's usually the plainest.

Experienced engineers know that software never stays in the conditions it was built under.

Requirements shift.

Teams reorganize.

Dependencies get swapped out.

Frameworks move forward.

Companies change strategy.

People leave the company.

New engineers show up with zero context on why things were built a certain way.

So the question worth asking isn't:

"How clean does this design look right now?"

It's:

"How costly will it be to change this five years from now?"

The following are the structural habits that make that kind of longevity possible.

1. Optimize for Deletability, Not Reusability

A lot of architecture guidance centers on reuse.

Make your components reusable.

Build generic services.

Add extension points.

Design plugin architectures.

Write abstractions for implementations you haven't built yet.

Reuse has its place.

But there's another quality that often matters more in a product that keeps evolving:

How easy something is to delete.

Features aren't permanent.

They get swapped out.

They get rebuilt.

They get folded into other features.

Sometimes the business simply loses interest in them.

Picture a React project organized strictly by file type:

src/
  components/
    BillingTable.tsx
    UserModal.tsx
    SubscriptionCard.tsx
  hooks/
    useBillingData.ts
    useUserData.ts
    useSubscription.ts  services/
    billingApi.ts
    userApi.ts
    subscriptionApi.ts

On the surface, this seems tidy.

Every kind of file has its designated folder.

But suppose the company decides, eighteen months down the line, to drop the billing flow entirely.

Where does everything tied to billing actually live?

You'd have to hunt across several different directories.

You find and remove BillingTable.tsx.

Then you spot useBillingData.ts.

Then some type definitions written specifically for billing.

Then a helper function only billing ever called.

Then a stylesheet.

Then an API call.

Then a test fixture.

Then a hook that started life as a billing hook but got renamed along the way.

The feature is gone from the product, yet remnants of it are still scattered across the codebase.

That's exactly how dead code piles up over time.

Organizing by feature instead makes the boundary far more obvious:

src/
  features/
    billing/
      components/
        BillingTable.tsx
      hooks/
        useBillingData.ts
      services/
        billingApi.ts
      types.ts
      index.ts

Now billing has one clear home.

If the business kills the feature, the first move is straightforward:

src/features/billing/

Delete the folder.

TypeScript will then surface anything else that still relies on it.

That's a much healthier kind of dependency to deal with.

Deletability is a form of maintainability

A module becomes easier to maintain when you can immediately tell where its responsibilities live.

That's part of why feature-based folder structures keep coming up in conversations about scaling large React codebases. Teams working on sizable applications often bring up colocation specifically because it shrinks the blast radius of any given change.

The point isn't to chase a perfectly organized directory tree.

The point is being able to quickly answer this:

"If this feature vanished tomorrow, what would I need to delete?"

If that question is hard to answer, your feature boundaries are probably too loose.

2. Don't Turn shared/ Into a Junk Drawer

There's a second trap that tends to show up once teams switch to feature-based architecture.

Anything that doesn't obviously belong to one feature gets dumped into shared/.

A few months later, you end up with something like:

shared/
  utils/
  helpers/
  common/
  services/
  components/
  hooks/
  types/

And at that point, shared/ quietly makes up half the codebase.

This introduces its own kind of coupling problem.

A good guideline to follow:

Code should move into a shared folder because several features genuinely rely on the same concept, not because you couldn't decide where else it should go.

A generic button component fits naturally in a design system.

An auth client might reasonably sit in a shared infrastructure layer.

A date-formatting helper could be shared too.

But a function like:

calculateEnterpriseRenewalDiscount()

almost certainly belongs to whichever feature owns that particular business rule.

Resist the urge to move things into global folders purely to make the directory tree look neater.

Shared code isn't free, because every feature that touches it becomes a potential dependent.

The bigger shared/ grows, the harder it becomes to know who's actually responsible for a given piece of behavior.

3. Build Defensive Adapters Around External Dependencies

There's a good chance your application will keep running long after some of its dependencies are gone.

You might rely on Axios today and switch to native fetch tomorrow.

You might use one analytics vendor now, and find your company migrating to a different one in a couple of years.

You might integrate an authentication library today, only to have new security requirements push you toward a different one later.

The fragile way to build things is to import these external packages straight into dozens of components.

import axios from 'axios';
import { trackMixpanelEvent } from 'mixpanel-browser';
export function CheckoutCard() {
  const handlePurchase = async () => {
    await axios.post('/api/checkout', payload);    trackMixpanelEvent('checkout_completed');
  };
}

At this point, your UI layer has direct knowledge of exactly which HTTP client and which analytics vendor you're using. Multiply that pattern across forty-five components, and swapping a vendor stops being a contained task — it becomes a change that ripples through the entire repository.

A boundary layer keeps the dependency swappable. For instance:

// src/shared/lib/analytics.ts
import mixpanel from 'mixpanel-browser';export const analytics = {
  trackCheckoutCompleted(
    orderId: string,
    amount: number
  ) {
    mixpanel.track('checkout_completed', {
      orderId,
      amount,
    });
  },
};

The component now talks to a concept defined by your own application:

analytics.trackCheckoutCompleted(orderId, amount);

It has no idea whether Mixpanel, PostHog, Segment, or some other tool sits underneath. If the vendor changes, the contract your application relies on can stay exactly the same.

But don't abstract everything

This point matters just as much as the previous one.

Wrapping a dependency in an adapter isn't inherently good design. If you build a custom interface around every small library you use, you can end up writing more code than the library itself contains.

The real question to ask is:

"Would replacing this dependency later be costly, or would letting it spread unchecked through the codebase be risky?"

If the answer is yes, an adapter is worth the investment. If not, calling the dependency directly is probably simpler and fine.

Senior engineering doesn't mean layering abstraction onto everything you touch. It means placing boundaries specifically where skipping one would likely cost you later.

4. Explicit Data Flow Beats Magic

One of the fastest ways to make a codebase confusing is to obscure where values actually come from.

Global event emitters are a textbook case.

eventBus.emit('USER_UPDATED', {
  id: user.id,
});

This line tells you an event fired. It says nothing about who's listening for it.

You could search the whole codebase and eventually find something like:

eventBus.on('USER_UPDATED', handler);

But there might be three separate listeners. One might have been added two years back. Another could be mutating global state. A third might be firing off an analytics call. Suddenly the real behavior triggered by that original function is scattered across the entire application instead of living in one place.

Now compare that to a contract that's stated explicitly:

interface UserCardProps {
  user: User;
  onUserRoleChange: (
    userId: string,
    newRole: Role
  ) => Promise<void>;
}

Here, the component openly declares what action it supports, and the parent openly declares what happens when that action fires. The flow of data is visible on the page.

Yes, this is more verbose than firing an anonymous event. But that extra verbosity earns its keep, since it leaves a traceable path through the code.

When someone unfamiliar with the component opens the file, they should be able to answer three questions without digging through the rest of the repository:

Where does this data come from?

Typically it's props, route parameters, a hook, or some clearly defined data-access layer.

What can change it?

A visible function call, a mutation, an action, or an explicit state update.

What happens when the user performs this action?

A direct call to a function whose implementation you can trace.

The less behavior you hide, the easier the whole system becomes to reason about.

5. Keep Business Logic Outside Framework Lifecycles

Frameworks are not permanent fixtures — that's one of the more reliable assumptions you can make when working on the frontend.

React alone has been through several major shifts. Class components fell out of favor. Hooks reorganized how stateful logic gets structured. Create React App was replaced in plenty of projects by tools like Vite or framework-native setups. Server-first rendering and newer routing approaches have reshaped how teams think about data fetching and where application boundaries sit.

The framework you're using right now may look nothing like what's standard five years from now. Your business rules, meanwhile, need to keep functioning regardless.

Take tax calculation as an example. A fragile version buries the actual logic inside a React hook:

export function useTaxCalculator(
  cartItems: CartItem[]
) {
  const [tax, setTax] = useState(0);
  useEffect(() => {
    let calculated = 0;    // 60 lines of tax calculation,
    // rounding rules,
    // country logic,
    // exemptions...    setTax(calculated);
  }, [cartItems]);  return tax;
}

Now your tax calculation is tied to React. Testing it means spinning up a React environment. Calling it from a server action becomes clunky. Running it inside a Web Worker becomes clunky. Porting it to a different UI framework becomes an expensive undertaking.

A better approach separates the two concerns:

export function calculateTax(
  cartItems: CartItem[],
  countryCode: string
): number {
  // Pure business logic
  return totalTax;
}

The React layer then simply calls into it:

const tax = calculateTax(cartItems, countryCode);

With this structure, the logic that actually matters has zero awareness of React. It can execute in any environment. It's testable with plain unit tests. A server process can reuse it directly. And it survives a UI framework migration without needing to be rewritten.

Frameworks belong at the outer edges

A helpful way to picture this is a layered diagram:

┌──────────────────────────────┐
│          UI Layer            │
│      React / Next.js         │
├──────────────────────────────┤
│       Application Logic      │
├──────────────────────────────┤
│        Domain Logic          │
│     Pure TypeScript          │
├──────────────────────────────┤
│       Infrastructure        │
│ APIs / DB / Vendors / SDKs   │
└──────────────────────────────┘

The nearer a piece of code sits to the center, the less it should depend on any particular framework or vendor library.

This doesn't mean every React project requires a full "Clean Architecture" setup.

It means you need to be clear about which parts of your code are genuinely React-specific and which parts represent your actual business rules.

Those are two distinct categories, and treating them as one is where trouble starts.

6. Avoid Turning Hooks Into Mini Applications

This pattern shows up repeatedly in React codebases.

It usually starts innocently:

function useUser() {
  // fetch user
}

Then, over time, requirements pile on.

function useUser() {
  // fetch user
  // loading state  // error handling  // permissions  // analytics  // transformations  // caching  // retry logic  // business rules  // notifications  // feature flags
}

Before long, what began as a simple hook has quietly grown into a 500-line application hiding behind an innocent-looking function name.

Hooks are genuinely useful.

But a hook shouldn't become a dumping ground for every concern just because it has convenient access to React state and effects.

A better approach is to have the hook delegate to smaller, focused pieces:

function useUser() {
  const user = useUserQuery();
  const permissions =
    calculatePermissions(user.data);  return {
    user: user.data,
    permissions,
    isLoading: user.isLoading,
  };
}

With this structure, the hook becomes an orchestration layer that wires things together.

It's no longer the entire architecture crammed into one function.

That boundary is far easier to maintain over time.

7. Write Architectural Decision Records, Not Endless Wikis

One of the most common sources of architectural decay isn't messy code at all.

It's lost context.

Here's how it typically happens: a developer makes a non-obvious call. The decision is sound, and everyone on the team at the time understands the reasoning behind it. Then that person moves on.

Months later, a new engineer stumbles across the unusual implementation and thinks:

"Why are we doing it this way? There must be a cleaner solution."

So they rewrite it, unknowingly reintroducing the exact problem the original decision was designed to avoid.

Take a dashboard built on Server-Sent Events instead of WebSockets as an example.

Without the backstory, a new developer might reasonably conclude:

"WebSockets are the more current standard. Let's switch."

But the original team may have picked SSE specifically because many enterprise customers sit behind restrictive corporate proxies that mishandle WebSocket connections.

That reasoning is invisible if you only look at the code itself.

This is exactly the gap that Architectural Decision Records are meant to fill.

For instance:

# ADR 003: Use Server-Sent Events for Dashboard Feeds
## ContextOur dashboard requires real-time metric updates.We evaluated WebSockets and Server-Sent Events.## DecisionWe chose Server-Sent Events because:1. Communication is strictly server-to-client.
2. SSE uses standard HTTP infrastructure.
3. Browser reconnection is supported natively.
4. The solution works reliably within our enterprise network environment.## ConsequencesIf we later require client-to-server
bi-directional streaming, we should
re-evaluate this decision.

With a record like this in place, the next engineer doesn't have to reverse-engineer the reasoning from scratch.

They can see the why right away.

A simple

/docs/adr/

folder in your repository can preserve years of institutional knowledge that would otherwise walk out the door with whoever leaves the team.

Document decisions, not everything

You don't need to maintain a sprawling, hundred-page wiki.

In most cases, the code itself should be clear enough to explain what it does.

What documentation should capture instead is the reasoning that code can't express on its own:

  • why a particular technology was chosen
  • why a more obvious alternative was passed over
  • why an unusual constraint exists at all
  • why a workaround that looks unnecessary is actually still required

Documentation earns its keep precisely when it captures context that would otherwise vanish along with the people who had it.

8. Design for the Engineer Who Joins After You

This might be the simplest litmus test for architecture meant to last.

Picture a scenario where, starting tomorrow, everyone who currently understands the system's inner workings leaves the company at once.

Would an incoming team be able to keep operating it?

If your honest answer is no, that doesn't necessarily mean your developers are lacking. It means the system has a hidden dependency on specific people's knowledge.

A system built to last should make its important behavior discoverable on its own.

Someone new should be able to open the repository and gradually piece together answers to questions like:

  • Where does this particular feature live in the codebase?
  • Which module is responsible for this behavior?
  • Where is this data actually coming from?
  • What external systems does this code rely on?
  • What assumptions is this code quietly making?
  • Why was this particular architectural choice made?
  • What is safe to change without breaking something else?

This is exactly why explicit boundaries carry so much weight.

A newcomer shouldn't have to learn the company's entire history just to understand what the code does.

The codebase itself needs to carry enough of that history along with it.

9. Keep the Blast Radius of Change Small

A good way to evaluate an architecture is to count how many files a routine change forces you to touch.

Picture a straightforward feature request: someone asks for a button that lets users export the billing report as a CSV file.

In a tightly coupled system, satisfying this might mean editing files scattered across:

components/
hooks/
services/
utils/
types/
global state/
shared helpers/

The engineer might end up touching a dozen files just to ship one button.

Compare that to a properly bounded feature structure:

features/
  billing/
    components/
    hooks/
    services/
    utils/

Here, the same change can stay almost entirely inside the billing feature's own folder.

This is what people mean when they talk about shrinking the blast radius of a change.

A small blast radius gives you:

  • Fewer regressions
  • Simpler code reviews
  • Faster delivery
  • Fewer merge conflicts
  • Easier testing
  • Safer refactors

Achieving this doesn't require an elaborate architecture. It requires boundaries that actually mirror how the product evolves in practice.

10. Stop Optimizing for the Architecture Diagram

A gorgeous architecture diagram can still hide a codebase that's miserable to work in.

You could check every one of these boxes:

  • following a Clean Architecture layout
  • applying SOLID design principles
  • inverting your dependencies properly
  • wrapping data access in repository patterns
  • generating objects through factory patterns
  • wiring things together with events
  • stacking several layers of abstraction on top of each other

and still turn a trivial feature into a multi-day slog.

Architecture is supposed to reduce complexity, not add to it. If your architectural layer introduces more concepts than the actual product does, something has gone sideways.

Often, the strongest architecture is the one nobody bothers to talk about, because engineers can simply read the code and follow it. In practice that might look like:

features/
  billing/
  checkout/
  accounts/

paired with a modest:

shared/
  ui/
  lib/

plus a handful of pure business-logic functions.

None of that sounds impressive. But if it still holds up and makes sense four years later, it's doing exactly what architecture is supposed to do.

What Senior Engineers Actually Optimize For

Senior engineers don't necessarily produce more elaborate code. What differs is the set of questions they ask before writing it.

A less experienced engineer might ask:

"How do I make this reusable?"

A senior engineer instead asks:

"Does this even need to be reusable?"

A less experienced engineer might ask:

"How should I abstract this?"

A senior engineer instead asks:

"What specific problem is this abstraction supposed to solve?"

A less experienced engineer might ask:

"Where should this utility function go?"

A senior engineer instead asks:

"Who actually owns this piece of behavior?"

A less experienced engineer might ask:

"How do we prepare for whatever requirements come next?"

A senior engineer instead asks:

"Which future change is likely enough to justify adding this complexity now?"

And perhaps the most telling question of all:

"What will this code look like once the person who wrote it has moved on?"

That question is really where long-term thinking in engineering starts.

Wrap-up: Durable Code Tends to Look Unremarkable

Code that keeps working well after years of change is rarely the code built on the newest framework, the cleverest design pattern, or the most elegant abstraction. It's usually just code with clear boundaries and unglamorous, sensible decisions — the kind where another engineer can open the repository and figure out what's going on without needing to track down whoever wrote it originally.

The underlying principles are straightforward:

  1. Organize by feature and ownership. Features should be easy to locate and, when the time comes, easy to remove.
  2. Favor deletability over maximizing reuse. Not every snippet of logic deserves to become a shared abstraction.
  3. Shield your application from third-party dependencies. Reach for adapters wherever a vendor change would otherwise ripple across a large part of the codebase.
  4. Prefer explicit data flow. A little extra, obvious code usually costs less than hidden, implicit behavior.
  5. Separate business logic from framework lifecycles. React's job is to render and coordinate — not to own every rule your business depends on.
  6. Keep hooks narrow in scope. Resist letting a custom hook grow into its own small application.
  7. Record architectural decisions. Save the reasoning behind unusual choices, not just a description of the current state.
  8. Limit the blast radius of changes. Ideally, a feature can change without you having to touch half the repository.
  9. Don't mistake complexity for quality. Piling on more layers doesn't automatically make an architecture better.

The highest praise a codebase can receive isn't:

"This architecture is remarkably clever."

It's:

"I understand this."

Because five years down the line, the original engineers will likely have moved on. The framework will probably be different. The design will have shifted. The product will have evolved. The business itself may look nothing like it does today.

But as long as the boundaries stay clear, the logic stays simple, and the reasoning behind key decisions is written down somewhere, the code can keep evolving right alongside everything else around it.

That's what durable software actually looks like.