The activity of enforcing logical boundaries between each of the architectural concerns. It allows for maintainable and testable code. In a SPA, this roughly means that code is divided into various layers.
- Core layer: global state management, authentication, session management, etc.
- Design system: your global design system combined with the styles, i.e. (S)CSS, coming with that.
- Domain layer: everything related to business logic + business related UI (non-generic UI components), confined into modules as little dependencies on other modules (as possible).
Things, like functions, types, constants, data, etc. need to live close to where it is being used. This allows for a better developer experience, but above all, better maintainability of applications.
Creating hasty abstractions is a common pitfall, resulting in over-engineered solutions that are harder to maintain. The goal is to choose between “don’t repeat yourself (DRY)” and “write everything twice (WET)”, or even the “rule-of-three”, based on the chance of change for different parts of your code base.
- Application layer: things here will not change often, or not at all. That’s why a DRY approach works well.
- UI layer: the global design system will not change often (mostly appended upon). That’s why DRY approach works well.
- Domain layer: Things will change often, both on the business logic, as well as the UI. A WET approach is preferred here, or even apply the rule-of-three.
NOTE: the running assumption is domain logic changes a lot. Avoid abstracting entire modules, or create dependencies between modules just for the sake of code-sharing.
src/
├── api/ // API wrapper, middleware, cache
├── components/ // Design system
├── modules/ // Domain layer
├── store/ // app state management (except remote)
├── styles/ // (S)CSS code
├── utilities/ // generic utility functions
├── app/ // index, router, generic pages
NOTE: this is a guideline, adjust where you see fit. For instance, you might want to add a directory with “constants”, or a directory around i18n.
Related business logic is co-located into modules. The modularity principle. Below the suggested structure for a module.
modules/
├── [module-name]/
│ ├── components/
│ ├── actions/
│ ├── helpers/
│ ├── pages/
│ ├── types.ts
│ ├── constants.ts
Pages are the main entry points for a router (e.g. from the src/app/router.ts
file) to point to. Components are used by pages, or by other modules to show specific information (e.g. widget on the dashboard).
It is recommended to use TypeScript to enable type-safety across the team. Standardising domain-related types can greatly help in achieving maintenance.
<ObjectName>
: type corresponding to data retrieved from the server, ideally in line with API definition.<ObjectName>Extended
: the type corresponding to the server, but extended with additional properties (e.g. including related data likeuser
based on theuserId
).I<ObjectName>
:client-side type that has a temporary properties, limited properties and more optional properties compared to the server type. Often used for “create” forms.
For example, in case of a projects
module, you will get IProject
, Project
, and ProjectExtended
.
Actions are not tied to a front-end framework themselves. By separating them, you achieve more testable and maintainable code. You can write wrappers to link actions to a framework (e.g. use a generic hook that provides loading state of asynchronous functions; use subscriptions for updates on remote state).
By co-locating validation and transformations within the action (making them part of the action), tests for the actions become more complete, and realistic. It avoids writing code for validation and transformation that cover cases that are not realistic.
Ensure only one function gets exported per file. This holds true for plain JavaScript/TypeScript, but also for the UI components written in any other format. By only allowing one export, you get forced to limit the overhead on IDE’s auto-complete feature, and make code navigation easier.
You can put more functions into a file for readability/maintainability purposes. These functions are only used by the exported function of the file.
A list of more generic guidelines and concepts that can help create a more maintainable codebase, but are not by default recommended, given they are more opinionated.