When I say “big”, what I really mean is Angular applications that are contributed to by >1 developer and are using automated build and testing, probably CI/CD.
Example Project Structure
Starting at the src
directory, treat each part of your application as a feature. Keep the app
feature small. It will produce your main.MD5HAS.js
on every rebuild, and have to be sent to the users of your application.
The features
directory is a peer with app
, and has nested directories for each feature. If the feature exposes a route, put that in the root of the directory.
src
├── app
│ ├── app.component.ts
│ ├── app.config.ts
│ ├── app.routes.ts
│ ├── components
│ │ └── nav-bar.ts
│ ├── data.ts
│ ├── pages
│ │ └── home.ts
│ └── services
| └── telemetry.ts
├── features
│ └── admin
│ ├── admin.ts
│ ├── components
│ │ ├── course-card.ts
│ │ └── track-card.ts
│ ├── pages
│ │ ├── add-course.ts
│ │ ├── add-offering.ts
│ │ ├── add-track.ts
│ │ └── todo.ts
│ ├── routes.ts
│ └── stores
│ ├── admin.ts
│ ├── courses.ts
│ ├── tracks.ts
│ └── with-courses.ts
├── shared
│ └── user-store.ts
Each feature (app
, as well as any code in the features
directory) should be as isolated as possible.
Some exceptions:
- The
app.routes.ts
may import theroutes.ts
file from any feature.- These should be lazy loaded with
loadChildRoutes
so that they split the build bundles.
- These should be lazy loaded with
- The
app.routes.ts
may import features that expose components.- Again, prefer lazy loading with
loadComponent
.
- Again, prefer lazy loading with
- If any feature (including
app
) uses a component, service or anything from another feature, it should be treated as suspect and probably disallowed.- “Promote” it to the
shared
directory. - For components, if there is a lot of churn on the component being imported, consider using a
@defer
so that it creates a new bundle.
- “Promote” it to the
The Goal With Features
Features are usually worked on by a small subset of the team, often in parallel with other work in the application. Features typically have a high rate of change (code churn) initially (while we get feedback from the team, users, testers, etc.). A developer working in a feature should be able to:
- Push new changes to the CI/CD pipeline as often as they like.
- These changes should almost never cause merge conflicts since the changes are localized.
- Note: I’ve even broken a single feature into multiple features to avoid merge conflicts.
- It’s best if a feature is only being actively developed by one developer at a time.
Some Rules:
- Features cannot ever use anything from any other feature.
- Features may import from the
shared
directory. - Features can promote things to the
shared
directory, but that comes with responsibility (see below). - Of course features can import things from
node_modules
.
About the shared
Directory
Sometimes I’ve done this as a package, but usually I find this not worth the effort. The shared directory is for things that are “up for grabs” across any feature in the application.
What that means, though, is that while a major change to something inside of a feature would have no impact on any other part of the application, any changes in the shared
directory may have unintended side effects in unknown places in the application.
Items (for example, components) in the shared
directory, by definition, do not have a specific use. They are shared and therefore general use. This increases the requirements for tests to detect regressions, and these tests are fairly “context free”. You tend to have to do more defensive coding on shared assets.
Likewise, if you are working in a feature and your are using something from the shared
directory (or from node_modules
!) make sure your tests prove your specific use of those imports.
Note: UI Testing for
shared
items is challenging. No good testing frameworks (other than, arguably, Cypress) allow you to do “component testing”. This is the #1 argument for putting these in a separate library that the Angular app cannpm install
. In that library you can use tools like Cypress component testing, or something like Playwright with StoryBook or developer created examples of their use that are tested against.
Using Sheriff To Protect Boundaries with ESlint
Sheriff is a great tool for enforcing module boundaries with ES-Lint. Instructions for installing and initializing are at the link.
Here’s a pretty general-purpose config for the above proscribed structure:
import { SheriffConfig, sameTag } from '@softarc/sheriff-core';
import { SheriffConfig, sameTag } from '@softarc/sheriff-core';
export const config: SheriffConfig = {
autoTagging: true,
enableBarrelLess: true,
modules: {
'src/features/<domain>': ['domain:<domain>', 'type:feature'],
'src/app': ['domain:app', 'type:app'],
'src/shared': ['type:shared'],
}, // apply tags to your modules
depRules: {
'type:feature': [sameTag, 'type:shared'],
'domain:app': [sameTag, 'type:shared', 'type:feature'],
'domain:*': [sameTag, 'type:shared'],
'type:app': [sameTag, 'type:feature', 'type:shared'],
'type:shared': [sameTag],
root: ['type:app'],
},
};
Summary
There are a lot of “rules” here, but I think the payoff is worth it in terms of less overall frustration, and increased velocity. Consider using a tool like es-lint to help enforce these rules.