Multi-entry-point folder structure and file conventions for scalable web apps
The design aspects discussed here seek to address the following real-life issues:
- How to structure a codebase repo so that the app evolves from a smaller to a larger one seamlessly, without imposing complexity ahead of time?
- How to maintain different rendering strategies (such as SSR, CSR) within a single app?
- How to painlessly and incrementally adopt a newer tech stack while still having older legacy code alongside running on the same server?
- How to make a codebase easier to navigate?
Hereβs how the code of an application can be arranged to address the scalability issues outlined above and to keep it clean and easy to navigate.
First off, weβll put all our appβs code in the π src directory to draw a clear boundary between the applicationβs own human-readable (and often human-made) code and various auxiliaries, like π node_modules, tool configs, a π dist directory with build artifacts, that will mostly stay outside π src.
π src
π const
π utils
π types
π public
π server
π const
π utils
π types
π middleware
π index.ts // runnable app server combining entry points
π ui // components shared across multiple entries
π entries
π [entry-name] // can be "main" for a start
π const
π utils
π types
π public
π server
π const
π utils
π types
π middleware
π index.ts // exports Express Router (or similar)
π ui
π [feature-name] // can be "app" or skipped in the beginning
π const
π utils
π types
π Component
π index.tsx // exports Component and type ComponentProps
π index.css
π index.tsx // optional CSR entry point
π lib // features as packages, patched third-party packages
π [lib-name]
The runnable application server is located in π src/server/index.ts, which is pretty straightforward to spot without prior knowledge of the codebase. While most directories shown above are optional and can be added as needed, note how they create a recurrent pattern from topmost to inmost parts allowing for common conventions being reused over and over.
Subdirectories of π entries contain the appβs entry points. A few typical use cases for different entry points include: an older and newer tech stack in the same app, an older and newer UI within a single app, a main app with a lighter marketing landing page or a user onboarding app, or multiple self-contained portions of a single app in general. An entry point can also serve an API to the rest of the app. Each entry point doesnβt have to map to a single route, but itβs convenient to have one parent route for an entry point.
As shown above, the appβs entry points replicate the basic app structure, too. They can be regarded as self-contained quasi-apps that can act largely independently from each other. For this same reason, cross-entry-point imports are strongly discouraged. Besides reducing the cognitive load of managing intertwined parts, this precaution also makes connecting and disconnecting an entry point nearly effortless.
Each level of the app, inside and outside the π entries directory, can contain auxiliary files arranged into the directories π const, π utils, π types, and optionally other domain-specific ones like π middleware.
To facilitate navigation through the codebase, we should make file names very straightforward and transparent about their contents. The common file managing convention boils down to the following rules: (1) Single export per file. This rule still allows to collocate the main export with a tightly related type export, such as a functionβs parameters type, in the same file, which is a good practice. (2) Files are named exactly as their export. With the same casing. For index files, this rule applies to the parent directoryβs name.
π const
π customValue.ts // export const customValue
π utils
π getCustomValue.ts // export function getCustomValue
π types
π CustomType.ts // export type CustomType
For the sake of clarity of the codebase, using index files should be limited to small atomic parts. Large barrel index files listing re-exports complicate the codebase navigation and create ambiguous module access points. Index files can be used, for example, for main exports of a self-contained feature or an app component (having π Component/index.tsx instead of π Component/Component.tsx is fine).
Note that public assets can be split across entry points and served independently by each entry point through their own π public directories to maintain a higher level of autonomy. To avoid duplication, resources shared across multiple entry points can still be located in the appβs top-level π src/public directory served from the appβs π server.
~
Adding these elements to the appβs codebase should make it more flexible regarding the common scalability issues and more comfortable to work with.
