Quick Start
In this section, we’ll cover all the basic LAD patterns.
This article will be enough to start getting value from using this pattern.
Further articles in the guides section will explore individual aspects of working with LAD.
What projects can be built with LAD
LAD is designed to work with major web frameworks: vue, react, svelte, solid.
In simple modifications, it will be useful even in the most basic projects.
The only thing is that LAD is not designed for library development. But you can always take some patterns if they are useful to you.
LAD modifications
The architecture is presented in 4 modifications:
- For small projects (up to 12 person/months of development)
- For medium projects (team up to 5-6 frontend developers)
- For large projects (more than one team)
- For a team working on multiple projects
This guide will describe the first 2 modifications. 3-4 will be described in other articles in the future.
What LAD consists of
For an external observer, these are just folders and files. In reality, these are abstractions and architectural boundaries.
We’ll talk about this in more detail later. But for now, nothing prevents you from thinking of them as folders and files.
The first thing LAD starts with is layer separation.
Main layer separation
As in most architectures, in LAD at the top level, code is organized by layers.
There are 4 top-level layers in total.
Directorysrc/
Directoryroot/
- …
Directoryfeatures/
- …
Directoryservices/
- …
Directoryshared/
- …
Differences from FSD
- In LAD
sharedunlike FSDsharedcan contain business logic. servicesare very similar toentities, but there are no restrictions on the semantics of “business object”featuresare large chunks of application functionality. Essentially, these are thepageswidgetsfeaturesentitieslayers in one.- There is no separate
pageslayer. Page code is located infeatures. If pages contain multiple features, then composition is located inroot - We call slices modules, and segments groups. Submodules are also allowed.
Root
The entry point layer. Here the project is launched, global configuration occurs, and features are connected into a single application.
The internal structure of root is not standardized and heavily depends on the application.
The layer where the most frequently changing logic should be located.
Examples of code that can be here:
app.tsxroot application componentroot-layout.tsxcommon layout for the entire applicationroot-header.tsxheader for the entire application. Can delegate tasks to components fromfeaturesglobal.cssglobal stylesrouter.tsxreact-routerinitializationproviders.tsxcomponent that uses global react providers
export function Providers({ children }: { children: React.ReactNode }) { return ( <StoreProvider store={store}> <QueryProvider client={queryClient}>{children}</QueryProvider> </StoreProvider> );}- In
next.jsapp remains itself, just try to move most of the logic tofeatures
Features
The main layer. Most of the application code should be located here.
Each folder in the features layer is an implementation of large independent chunks of functionality
Example features for a task planner
Directorysrc/
Directoryroot/
- …
Directoryfeatures/
Directoryauth/
- …
Directorytask-list/
- …
Directorysidebar/
- …
Directorysettings/
- …
Interaction restriction with root
This extremely important restriction gives us the following:
rootcontains frequently changing things. Dependency on frequently changing code makes features unreliable sdp- Import from root is almost always a circular dependency. Circular dependencies are always bad, both for the compiler and for the brain. adp
Feature structure
Each feature is essentially a module!
Since modules in LAD can be large, their structure is described in detail below.
Now it’s important to understand that a feature can contain everything: display components, logic, infrastructure, and pages.
The main criterion is that all these things should have high semantic cohesion.
Feature interaction
There’s a difference between “small” and “medium” modifications here.
Medium modification
Directorysrc/
Directoryroot/
- …
Directoryfeatures/
Directoryauth/
- …
Directorytask-list/ // from task-list you cannot import auth
- …
This restriction is very important in medium and large projects to be able to consider features as independent blocks.
- This greatly increases understandability. Since you can consider a feature in isolation from others
- Increases reliability. Since changes in one feature are less likely to break another
- Protection from circular dependencies.
In reality, completely isolated features are rare. Therefore, for feature interaction with the prohibition on direct import, loose coupling patterns or dependency inversion are used.
Sounds complicated, but in fact it’s: connection through shared state, slots, render props, events, context, and dependency injection.
More about DI
This is the most complex part of LAD. For this reason, in the “small” modification, it’s impractical to impose this restriction.
Small modification
In the small modification, direct import is allowed. But this doesn’t cancel that there should be as few of these imports as possible!
The fewer connections between features there are, the better. Ideally, there should be none at all.
Services
Layer of reusable business modules. Can store not only logic but also presentation.
Like a feature, each service is a self-sufficient module. But unlike a feature, it doesn’t implement application functionality, but helps features do their work.
Most often modules in services are needed if there’s a large amount of logic reused between features.
Example services for a task planner
Directorysrc/
Directoryroot/
- …
Directoryfeatures/
Directoryauth/
- …
Directorytask-list/
- …
Directorymanage-settings/
- …
Directoryservices/
Directorysession/
- …
Directorysettings/
- …
In this case, session most likely contains session storage used in most other features.
And settings stores settings that are edited in the manage-settings feature and used in task-list.
Interaction restriction with root and features
Just as features cannot import root, services cannot import features and root.
This is done for the same reasons. But for services, this is even more important. They are most often reused in multiple places.
Dependency on more frequently changing features would make services unstable and endanger several other features at once.
Service interaction
There’s no unambiguous solution here. Basically, I allow service interaction with each other.
Since most often dependency inversion at this level causes a lot of complications.
But if problems appear, the same restriction as for features is introduced.
Shared
Application core layer. Here are things that are used throughout the application.
Try to place only the following in shared:
- Global business types. Types rarely change and don’t cause bugs
- Global infrastructure. Store instances, notifications, internationalization, theme Usually such infrastructure is used widely and changes rarely
- Global constants linking the application. For example, routing constants and .env reading result
Important exceptions:
Essentially uikit and api shouldn’t be in shared.
These are extremely frequently changing modules. But all attempts to move them from shared failed.
So know that uikit and api are eternal sources of bugs. And treat them accordingly.
The shared structure is not rigidly standardized, but always similar.
Example of standard shared
everything listed below is optional and may not be needed in your case
Directorysrc/
Directoryshared/
Directorydomain/ // global business types. Needed for infrastructure and entire application work
- ids.ts
- events.ts
- session.ts
- user.ts
Directoryui/ // application uikit
Directorykit/
- button.tsx
Directorytable/ // reusable table component
- …
Directoryapi/ // usually automatically generated api instance
- api-instance.ts // axios instance
- generated.ts
Directorymodel/ // working with global data
- routes.ts // routing constants
- config.ts // getting access to .env
- store.ts // redux instance
Directorylib/ // Global infrastructure and helpers
Directorynotifications/
- …
Directoryi18n/
- …
Directoryreact/
- use-mutation-observer.ts
- date.ts
domain, model, ui, lib
All shared code is divided into standard groups:
domain: The most important business types and rules. This group should be isolated and not depend on anyonemodel: Working with global stateui: Global componentslib: global infrastructure modules
Interaction restriction with root, features and services
shared is the layer where root logic is located. It cannot directly work with any other layer.
While it can and will be imported by all other layers.
This rule is extremely important and should not be violated. shared is a dangerous layer. You always need to be on guard with it)
Layer schema
”Small” modification
”Medium” modification
Module structure
features and services - in LAD contain modules. But if you wish, you can create modules in root and shared as well.
Also, LAD supports the concept of sub-modules, so modules in LAD can be very large.
Essentially, modules in LAD can be considered as mini-applications that have their own architecture.
Module evolution stages
We’re against overhead, so modules have evolution stages, from simple to complex.
Always when creating modules start with the simplest stages, and then refactor if required.
In our experience, this is the most effective approach! Excessive architecture is worse than its lack.
Stage 1: Single file module
Yes, the simplest modules can consist of only one file!
Here’s an example of the todo-list feature at the very first stages
todo-list feature at the very first stagesimport { useState } from "react";
export function TodoListPage() { const [todos, setTodos] = useState([]); const [input, setInput] = useState("");
const addTodo = (e) => { e.preventDefault(); if (!input.trim()) return; setTodos([...todos, { id: Date.now(), text: input, done: false }]); setInput(""); };
const toggleTodo = (id) => { setTodos( todos.map((todo) => todo.id === id ? { ...todo, done: !todo.done } : todo, ), ); };
const deleteTodo = (id) => { setTodos(todos.filter((todo) => todo.id !== id)); };
return ( <div className="max-w-md mx-auto p-4"> <h1 className="text-2xl font-bold mb-4">Todo</h1>
<form onSubmit={addTodo} className="flex mb-4"> <input value={input} onChange={(e) => setInput(e.target.value)} className="flex-1 border p-2 rounded-l" placeholder="Add todo..." /> <button type="submit" className="bg-blue-500 text-white px-4 rounded-r hover:bg-blue-600" > Add </button> </form>
<ul> {todos.map((todo) => ( <li key={todo.id} className="flex items-center mb-2"> <input type="checkbox" checked={todo.done} onChange={() => toggleTodo(todo.id)} className="mr-2" /> <span className={`flex-1 ${ todo.done ? "line-through text-gray-400" : "" }`} > {todo.text} </span> <button onClick={() => deleteTodo(todo.id)} className="text-red-500 hover:text-red-700" > × </button> </li> ))} </ul>
{todos.length > 0 && ( <div className="mt-4 text-sm text-gray-500"> {todos.filter((t) => !t.done).length} items left </div> )} </div> );}Most often modules quickly outgrow this stage. But sometimes they don’t!
Working with a file up to 400 lines is acceptable.
At the same time, the development and refactoring speed of such a module is higher than a module of 20 files with 20 lines each.
Don’t neglect this approach, especially at the prototyping stage.
Stage 2: Flat module
Your file has become larger than 400 lines and uncomfortable. What do we extract components/hooks/ui/model?
Not yet, it’s too early!
There’s nothing worse than a folder with one file.
The second evolution stage is creating a flat structure of heterogeneous modules.
Here’s an example of the todo-list feature at the second stage:
Directorytodo-list/
- todo-list-page.tsx
- use-todo-list.tsx
- api.tsx
- use-intersection-observer.tsx
- index.ts
Here we simply divided the code into functions. And then moved them to separate files.
We name files according to their content. We avoid names like hooks components.
This number is derived empirically and is related to the concept of Miller’s wallet.
Public api
At this stage, it’s not clear which code is intended for external use and which should remain internal.
Therefore, we add public-api.
This is an index.ts file where you re-export (sometimes with name changes) externally available elements.
export { TodoListPage } from "./todo-list-page";For those with next.js
If there are no side effects in index files, then tree-shaking works correctly in vite.
With next.js, we’ve encountered problems. So a different approach with public-api is often used there.
All private files start with _, and public ones without. Thus, it’s not necessary to create only one public file.
Directorytodo-list/
- todo-list-page.tsx
- _use-todo-list.tsx
- _api.tsx
- _use-intersection-observer.tsx
Explicit dependencies (experimental)
When modules interact with each other, it’s often extremely difficult to track all module dependencies on other modules.
Therefore, all dependencies used inside can be re-exported through a special deps.ts file.
This is especially important for services and features if cross imports are allowed (from feature to feature, from service to service)
Example:
Directoryfeatures/
Directorysettings/
- …
Directoryauth/
- …
Directorytodo-list/
- index.ts
- deps.ts
- todo-list-page.tsx
- use-todo-list.tsx
- create-todo-form.tsx
deps.tsx
export { useSession } from "@/features/auth";export { useSettings } from "@/features/settings";todo-list-page.tsx
import { useSession, useSettings } from "./deps";Sub modules
Essentially, when we made such division, we got a module that consists of several single-file modules.
But they don’t have to be single-file. Any child module can be at any evolution stage.
Directorytodo-list/
- index.ts
- todo-list-page.tsx
- use-todo-list.tsx
Directorycreate-todo-form/
- index.ts
- create-todo-form.tsx
- use-create-todo.tsx
Nesting dangers
Thanks to submodules, you can stay at the second level for a very long time.
But deep nesting is not as good as it seems. Deep tree structures are complex to understand. Homogeneous lists are much more comfortable to read (the features layer is just an example of a homogeneous list).
Therefore, most often to overcome the limitation of ~6 elements, it’s better to prefer creating groups over submodules.
Stage 3: Grouped module
As mentioned above, if for heterogeneous folders < 6 elements is comfortable. For homogeneous folders, this number sharply increases (up to 20 elements. Heavily depends on homogeneity).
Therefore, we can divide submodules into groups, thus greatly increasing the allowable module size.
What is a group
A group is a combination of several modules based on a common feature:
Group examples:
- components
- hooks
- services
- features
- ui
- model
- lib
- api
By the way, all layers are also groups.😉
Standard groups
There are quite successful groups that have shown themselves well:
Group: ui
Essentially, this is a combination of all components, rarely hooks, that are entirely responsible for display. And don’t carry complex logic.
Examples:
- todo-list-page.tsx
- todo-card.tsx
- todo-form.tsx
- use-render-arrows.tsx
Group: model
The group where the main work with data lies.
If you’re on pure react, here lie hooks that manipulate data apart from display.
If you have a state manager, then all the logic of working with the state manager will lie here.
Examples:
- use-todo-list.ts
- todo-list.slice.ts
- todo-list-store.ts
- todo-item.ts
Group: lib
The group where infrastructure code is located. This code most often provides more convenient wrappers over browser api and libraries. Or simply simplifies routine tasks.
Examples:
- use-mutation-observer.ts
- date.ts
Group: api
Group for code working with api and contract types.
Group: domain
If the logic in model becomes very complex.
Then code describing the most important business processes:
- Discount calculation
- Vacation calculation
- Progress getting
- Coordinate calculation when moving an element on a map
Can be moved as pure functions to the domain group.
Also in domain are all types over which these pure functions perform manipulations.
Examples:
- map-node.ts
- get-intersections.ts
- compute-next-lesson.ts
Group: view-model
In some cases, the module contains a large amount of logic that processes user input.
Usually this happens if dnd or animations are implemented.
In such case, this code can be moved to a separate view-model group.
Example:
- use-dnd.tsx
- use-animation.tsx
Nested groups
Inside a group, you can group modules by other features as well.
This works exactly the same as with submodules. Just don’t forget that excessive depth is inconvenient.
Example:
Directoryui/
Directoryfields/
- file-field.tsx
- text-field.tsx
- select-field.tsx
- date-field.tsx
- create-user-form.tsx
- update-user-form.tsx
Example module with groups
Directorytodo-list/
- index.ts
- api.ts
Directorymodel/
- todo-item.ts
- use-create-todo.ts
- use-update-todo.ts
Directoryui/
Directorypages/
- todo-list-page.tsx
- todo-details-page.tsx
Directoryfields/
- file-field.tsx
- text-field.tsx
- select-field.tsx
- date-field.tsx
- create-todo-form.tsx
- todo-details-form.tsx
- todo-item.module.css
- todo-item.tsx
Groups and submodules
The appearance of groups doesn’t cancel submodules. Sometimes instead of dividing code into groups, it’s better to split all code into several submodules.
Directorytodo-list/
- index.ts
- api.ts
Directoryui/
- file-field.tsx
- text-field.tsx
- select-field.tsx
- date-field.tsx
Directorytodo-list/
- index.ts
- todo-list-page.tsx
- create-todo-form.tsx
- use-create-todo.ts
- todo-item.tsx
Directorytodo-details/
- index.ts
- todo-details-page.tsx
- todo-details-form.tsx
- use-update-todo.ts
When to move to the next stage?
At this stage, you can develop modules of any size.
But there’s an important problem here - connections between submodules are chaotic, and it can be very difficult to understand them.
If you’ve encountered this problem, the next stage will help you:
Stage 4: Module with compose
This is a complex but extremely powerful pattern for fighting chaotic dependencies.
Its closest analogue is the DIP principle from SOLID.
The main essence of this pattern is to remove dependencies between submodules in model and ui.
For this, loose coupling tools are used.
(for ui slots and render props, for model events, DI, or simple connection through parameters)
After we get a set of independent elements,
all this is combined in special mediator components.
For them, I usually create a separate compose group.
Connections between components in compose are allowed.
Example:
Directorytodo-list/
- index.ts
- api.ts
Directorycompose/
- todo-list-page.tsx
- todo-details-page.tsx
- create-todo-form.tsx
- todo-details-form.tsx
Directorydomain/
- todo-item.ts
Directorymodel/
- use-todo-list.ts
- use-delete-todo.ts
- use-create-todo.ts
- use-update-todo.ts
Directoryui/
Directoryfields/
- file-field.tsx
- text-field.tsx
- select-field.tsx
- date-field.tsx
- todo-page-layout.tsx
- todo-item.tsx
- common-fields.tsx
- update-button.tsx
- delete-button.tsx
Then todo-list-page.tsx would look something like this:
export function TodoListPage() { const todoList = useTodoList(); const deleteTodo = useDeleteTodo(todoList); const createTodo = useCreateTodo(todoList); const updateTodo = useUpdateTodo(todoList);
return ( <TodoPageLayout createForm={<CreateTodoForm createTodo={createTodo} />} todos={todoList.list.map((item) => ( <TodoItem key={item.id} item={item}> <UpdateButton onClick={updateTodo.bind(item)} /> <DeleteButton onClick={deleteTodo.bind(item)} /> </TodoItem> ))} /> );}Where you can read more
Documentation is still actively being developed. Later there will be a separate article about this pattern. Now you can study the following materials:
Stage 4 limitations
The main limitations here are technical. Not all tools support loose coupling. And not in all situations does this give the needed performance.
But in most situations, this approach helps greatly reduce module complexity!
Conclusions on module evolution
Maybe you’re already confused by all the module variants presented here.
This is normal, such flexibility slightly complicates entry.
Upon deeper consideration, it turns out that these are all variations around three concepts: module group and public-api.
In fact, this approach both standardizes approaches. And provides flexibility.
As your modules become more complex, you can build convenient and maintainable code from module and group building blocks.
While not spending time on constant overhead from inconvenient rituals and patterns.
What’s next?
Using the advice from this guide, you can already start using LAD now.
If you want to understand how this all actually works. Come read the advanced part of the documentation.
Now this part is just starting to develop, but over time more and more articles will appear there, revealing concepts and patterns.