Scalable front-end architecture
How we organize our projects
Some frameworks have a very well-defined set of conventions for how the project should be organized. If you're working with a framework like that, I'd tell you to do what they recommend.
One of the major benefits of using a framework is the ability to easily jump into any project and know how to navigate it. If you do things your way, you lose that.
But even Angular – that I believe to be the strictest front-end framework – doesn't address everything in its style conventions, so you do need to define some rules yourself.
In this article, I'll show you how we (me and my team) organize our front-end projects.
Repositories
First, the front-end is always in an isolated repository. We don't have a single repository for the whole project. Actually, we generally have three repositories for each project.
- Front-end
- Back-end
- API client
That structure is super important when you're working with clients because they don't want every developer to have access to everything, and this allows more access control.
I'll leave our back-end organization for another article because a lot is happening there. There's even an ongoing project that we're migrating to event sourcing and that back-end has multiple containers communicating with each other. It's beautiful, but it's also too much and deserves its own article.
Before getting to the front-end, I want to explain what's the API client repository.
API client
The API client is a framework-agnostic library responsible for communicating with the back-end.
Honestly, if you take just one thing from this entire article, I hope it's creating API clients. It cleans up the front-end so much! Instead of testing and writing API calls along with your components, just do it in another project entirely and leave the front-end codebase with just the interface.
We write our API clients using TypeScript with all strict flags enabled, so they all provide a great amount of type safety.
The API clients also have a lot of tests. We generally use Jasmine, unless the client wants us to use something else. Most of the time, we have not only browser tests (running with Karma), but also server-side tests with Node.
The master
and dev
branches are protected with a bunch of continuous integration checks. We have CI checks for linting, compiling, browser testing, server-side testing, and typescript compatibility.
We also have a CI process that runs when we create a release, and this process publishes the package on NPM (if it's supposed to be public) and on the GitHub Package Registry (which is what we use the most because it offers private packages for free).
Folder Structure
The API client folder structure is divided by API modules. So if we have an API with three major modules, we'll have a folder for each. We call those folders "feature modules" (that was stolen from Angular).
Inside a feature module, we have types and a bunch of isolated functions. For example, let's say we have a users module. Inside it, there'll be an UsersService
which is an interface with all the possible methods to interact with the users API.
export interface UsersService {
readonly get: {
readonly one: (userID: ID) => Promise<User>;
readonly many: (userFilters: UserFilters) => Promise<Array<User>>;
readonly all: () => Promise<Array<User>>;
};
readonly create: {
readonly one: (data: CreatableUser) => Promise<User>;
};
readonly update: {
readonly one: (data: UpdatableUser) => Promise<User>;
};
readonly delete: {
readonly one: (userID: ID) => Promise<void>;
};
}
TypeScriptexport interface UsersService {
readonly get: {
readonly one: (userID: ID) => Promise<User>;
readonly many: (userFilters: UserFilters) => Promise<Array<User>>;
readonly all: () => Promise<Array<User>>;
};
readonly create: {
readonly one: (data: CreatableUser) => Promise<User>;
};
readonly update: {
readonly one: (data: UpdatableUser) => Promise<User>;
};
readonly delete: {
readonly one: (userID: ID) => Promise<void>;
};
}
There will also be a file called create-users-service.ts
that exposes a function to create a UsersService
and a file for each API call, like get-many-users.ts
and update-one-user.ts
.
Models and utilities are also there, so there'll be files like user.ts
, updatable-user.ts
, is-valid-user.ts
, and so on...
In the front-end. We install the API client just like a regular Node dependency because it is a regular dependency.
Front-end
Now let's get to the front-end.
Most of our clients use Angular, that's my framework of choice, so I guess that ends up attracting more Angular clients. But we actually work with other frameworks too, a client might want to use React or Elm, that's fine too.
I'm saying that because as I've mentioned at the beginning, some frameworks already have a well-defined way of organizing things, and we always put the framework conventions above ours.
That said, our front-end folder structure is very similar to the API client's structure. We have a folder for each major module and inside that folder, there are two other folders (sometimes three).
- Components
- Pages
- Helpers (optional)
The components/
folder has pure components. That means components that always render the same thing given the same inputs. Sometimes that's not true, we have components that receive an ID as input and grab the object with that ID from a store. The same ID could result in a different UI if the object was updated. But that's ok, they are "pure" enough (if there is such a thing).
The pages/
folder has page-level components. They don't receive inputs because they don't have a parent component to pass inputs to them. Instead, they use the URL as an input. For example, a page-level component that renders on /users/:userID
can parse the URL to grab the userID
.
Some feature modules also have a helpers/
folder, that's where we store shared functionality between the pages and components, which could be functions, services, types, whatever.
Now, out of feature modules, we also have a shared/
folder, that's where we keep shared modules, which means, modules that don't have pages. For example, we could have a shared/notifications/
module with all the services, functions, and components related to notifications. That module would then be imported into the feature modules that require it.
So, everything is divided into modules. If a module doesn't have pages, it's a shared module and goes to shared/
, if it does have pages, it's a feature module and has its own folder in the root structure.
Then we have the main module, which imports all the feature modules and registers the routes.
Dependency Flow
For that folder structure to work, it's important to respect the dependency flow.
- Shared modules can only import from other shared modules.
- Feature modules can only import shared modules.
- Helpers can only import shared modules.
- Pure components can only import helpers and shared modules.
- Page-level components can only import helpers, pure components, and shared modules.
- The main module can import anything.
Also, all files and modules can import regular Node modules.
If you don't respect the dependency flow, you'll have circular dependencies and everything will break.
Conclusion
I can't leave links for real repositories because they're all private. But we made template repositories for an API client and an Angular front-end, links are in the description. I hope that helps. send a tweet if I've missed anything, we'll do our best to answer your questions.
If you want to know how your company project would look like using our conventions, you can hire us. We're not an agency, we're a team, and I'm personally responsible for every project. For that reason, we have a limited amount of clients, that's not a marketing stunt, it's real, I can't be personally responsible for too many projects. Go to lucaspaganini.com and tell me, how can we help you?
Have a great day, and I'll see you soon!