arach.dev
publishedarchitectureelectronreactrefactoring

Honest Source Trees for Electron and Web Apps

A good source tree should tell the truth about where an app lives, where features live, and where reusable UI actually starts and stops.

There is a specific kind of pain that shows up in mature apps long before the code is technically broken.

The app still runs. The tests still pass. New features still ship.

But the tree starts lying.

You open a folder named ui, and it turns out to contain routing, feature orchestration, controller hooks, boot logic, and half of the app's real state transitions. You open components/ui, expecting buttons and inputs, and instead find feature-aware panels, composite screens, and domain logic disguised as reusable primitives. You open shell, and realize it is not the shell at all. It is the application.

That is the moment when structure becomes a product problem, not just a taste problem.

Thesis

A source tree is one of the first interfaces in a codebase. If it tells the truth, people move faster. If it lies, every feature becomes a placement argument.

The source tree is documentation

A source tree answers questions before a single file is opened:

  • Where does the app actually live?
  • What is a feature versus a primitive?
  • What is Electron runtime code versus renderer code?
  • Where should new work go?
  • Which names are implementation details, and which are product concepts?

When the tree answers those questions honestly, the codebase compounds in the right direction. When it does not, every new addition reinforces confusion.

This matters even more in Electron apps because there are usually at least three architectural worlds living side by side:

LayerOwnsShould Not Own
Electron runtimemain process, preload, native integration, packagingrenderer feature state and UI composition
Web rendererroutes, providers, shell, feature UI, client interactiondesktop runtime concerns and generic shared domain logic
Shared coreproduct logic, protocol-ish behavior, typed orchestrationview composition and platform-specific entrypoints

If those worlds are not named clearly, they leak into each other.

The smell: ui becomes the whole app

One of the most common failure modes in React and Electron codebases looks like this:

A tree that started small and drifted
This shape is fine for a tiny renderer. It gets misleading fast once routing, feature state, and controller logic move in.

This often starts out reasonable. A small renderer gets a ui/ folder. Then the app grows. Routing lands there. Feature state lands there. Controller hooks land there. Larger screens land there. By the time the product has real surface area, ui/shell is no longer the shell. It is the entire renderer app.

At that point, the problem is not that the names are imperfect. The problem is that the names are false.

If the real application lives in ui/shell, contributors will keep putting application code into a folder that claims to be presentation-only. If components/ui contains feature-aware code, contributors will keep treating feature boundaries like styling boundaries.

You can refactor hundreds of lines out of one component and still have the wrong architecture if the tree keeps telling the wrong story.

Better structure starts with honest roots

For a hybrid Electron and web app, I want the top-level structure to reflect real runtime boundaries first.

A tree that tells the truth

The exact folder names are less important than the fact that each area corresponds to a real architectural role:

electron/ is runtime code

Desktop entrypoints, preload behavior, native bindings, and packaging all belong to the desktop runtime layer.

web/app is the renderer application

Routes, providers, shell composition, and renderer-local wiring should have one honest root.

web/features owns product areas

Messages, agents, settings, and onboarding should carry their own components, hooks, and feature-specific helpers.

primitives are a leaf

Reusable low-level UI can live in one place, but it should not be the umbrella for the app itself.

components/ui should be a leaf node

components/ui is useful only when it means something narrow and true.

It should contain things like:

  • buttons
  • dialogs
  • form controls
  • tabs
  • badges
  • low-level composition helpers

It should not contain:

  • settings panels
  • messaging surfaces
  • agent inspectors
  • onboarding flows
  • application shells

A simple test is this:

If the component knows your product's domain language, it is not primitive UI.

The fastest way to rot a codebase is to let components/ui become the junk drawer for important visual stuff. Once that happens, teams stop building features and start building piles.

Features should own their own logic

A lot of structural drift comes from controller glue living in shared hooks long after the app has real feature boundaries.

If a hook assembles message state, selection state, mention scoring, optimistic sends, message thread projections, and feature-specific view props, that is usually not a generic app hook. It is a feature hook. It belongs with the messages feature.

The same rule applies more broadly:

  1. Message components should live with the messages feature.
  2. Agent surface logic should live with the agents feature.
  3. Settings-specific view models should live with settings.
  4. The root app should compose features, not absorb their internals.

Even a small shift like this changes the character of a codebase:

A healthier renderer split

Once that exists, refactors become local. Import paths become self-explanatory. “Where should this go?” stops being a recurring debate.

Good refactors rename boundaries before they rename details

One trap in structural refactors is spending too much effort renaming symbols while leaving the architecture intact.

The better order is:

  1. Establish the right root.
  2. Move the code under the right root.
  3. Fix aliases and entrypoints.
  4. Extract real feature folders.
  5. Clean up vocabulary inside those boundaries.

That order matters because boundary changes simplify everything that comes after.

If imports are aliased directly to a leaf like src/ui/shell/*, every future move becomes a global rewrite. If the public package surface still exports old entrypoint names, the old architecture keeps leaking even after the internal tree improves. If the docs say ui is presentation-only while the real app lives there, contributors will keep reinforcing the lie.

The first job is to make the tree tell the truth.

The goal is not elegance. It is truth.

I do not care whether a tree looks clever.

I care whether it tells the truth about the system.

If the real app is a web renderer, call it web/app. If something is a feature, call it a feature. If something is a primitive, make sure it actually is.

The best structures are not the most abstract ones.

They are the ones that make it obvious where the next line of code belongs.