System design frontend en entreprise : 8 décisions
Le system design frontend en entreprise relève du jugement, pas du glossaire : huit domaines, les quelques concepts qui comptent, et quand choisir quoi.

Un frontend d'entreprise survit à son framework, à sa première équipe et, le plus souvent, à son premier architecte. Cela change les concepts qui comptent. Les concepts n'ont jamais été le plus difficile. Le plus difficile, c'est de savoir lesquelles ignorer.
La plupart des contenus sur le system design frontend préparent à un entretien : concevoir un fil d'actualité en 45 minutes, au tableau blanc, pour personne. Les frontends d'entreprise échouent autrement. Ils ne cassent pas le premier jour. Ils cassent la septième année, quand la décision que personne n'a prise exprès se retrouve dans quatre cents fichiers.
Je travaille dans le logiciel depuis plus de vingt ans, à construire des applications d'entreprise. Cette série, c'est ce que je dirais à une équipe qui en démarre une, ou qui en hérite.
Ce qui fait un frontend « d'entreprise »
Ce n'est pas la taille du . Ce sont cinq choses que les petites applications réunissent rarement en même temps.
La durée. Le code vit plus longtemps que le framework. Le support d' a pris fin en janvier 2022. Les applications construites dessus, elles, ne se sont pas arrêtées. J'ai participé à plusieurs migrations d'AngularJS vers , dans des équipes et des rôles différents. La leçon, à chaque fois : les frontières tracées la première année décident de ce que vous pourrez supprimer la dixième.
Les équipes. Plusieurs équipes dans une même base de code, souvent un seul . Les lignes qui séparent leur code sont l'architecture, que quelqu'un les ait tracées ou non.
Les règles. La banque et la santé imposent des règles que le frontend ne peut pas écarter d'un revers de main. Le n'est « efficace que lorsqu'il est mis en œuvre dans du code serveur de confiance ou des API serverless », et le contrôle d'accès défaillant garde la première place du Top 10 2025. Dans l'Union européenne, l' couvre les services bancaires fournis aux consommateurs après le 28 juin 2025. Pour les banques concernées, l'accessibilité y est une obligation, pas un bonus.
Les rôles. Le même écran ne se présente pas de la même façon pour un client, un conseiller et un auditeur. Quelqu'un doit décider où vit cette logique.
Les langues. Au Maroc et dans le Golfe, l'arabe s'écrit , le texte français est souvent plus long que l'anglais, et la monnaie marocaine, le dirham (MAD), a son propre format. Une mise en page construite avec left et right casse le jour où l'arabe arrive. Les décrivent les directions « par rapport au sens d'écriture du contenu, plutôt qu'à une direction physique », et ce seul choix décide si le RTL sera une petite tâche ou une réécriture.
En entreprise, la décision survit à ceux qui l'ont prise. Concevez pour l'équipe suivante.
Les huit parties
Huit domaines et, dans chacun, seulement les cinq à sept concepts qui changent une décision.
- Architecture. Des couches, des frontières imposées, monorepo ou non, le plus souvent non, et le coin que vous gardez supprimable.
- État. Qui possède chaque morceau d'état, et combien de temps il vit. Données serveur, UI, formulaires, URL.
- Réseau et cache. Une seule question, donc une seule partie : où vit cette donnée, et quand est-elle fausse ?
- Rendu. Beaucoup d'applications d'entreprise accessibles après connexion n'ont pas besoin du rendu serveur. Quand elles en ont besoin, et ce que cela coûte.
- Performance. Des budgets qui font échouer le , mesurés sur les appareils que vos utilisateurs ont vraiment.
- Sécurité. Le frontend est la partie non fiable. Ce que le client peut protéger, et ce qu'il ne fait qu'afficher.
- Accessibilité. Une obligation légale, et c'est dans le qu'elle se gagne ou se perd.
- Langues, RTL et monnaie. L'arabe, le français et le dirham dès le premier composant, pas comme un projet pour plus tard.
Le cache va avec le réseau, parce qu'un cache sans propriétaire est un bug à retardement. Le RTL a sa propre partie, parce que pour les lecteurs au Maroc et dans le Golfe, ce n'est pas une sous-rubrique de l'accessibilité.
Commencez par la partie 1, l'architecture.
Comment chaque partie est construite
Chaque partie suit le même schéma :
- Les quelques concepts qui comptent, chacun avec une ligne qui dit pourquoi.
- Quand choisir quoi. ou , ou , monorepo ou . Le compromis en mots simples, Angular d'abord, React à côté quand le choix diffère.
- Ce que j'ai laissé de côté, et pourquoi. Un concept qui paraît important mais change rarement une décision dans une application d'entreprise.
- Un exemple sur Atlas Bank, l'application de démonstration ouverte de la série L'IA avec Angular : une banque marocaine imaginaire, construite en monorepo , avec des que
npm run lint:archfait respecter et deux coins legacy (un écran de virement enNgModuleet un écran de bénéficiaires en AngularJS). Le dépôt est public. Jamais de code client.
Un glossaire vous dit ce que veut dire un mot. La production vous dit quels mots vous pouvez ignorer.
Dès lundi
- Prenez l'écran le plus ancien de votre application. Pour chacun des huit domaines, demandez-vous : a-t-on décidé exprès, ou est-ce arrivé par défaut ?
- Notez celui auquel vous ne savez pas répondre. Ce sera votre premier (ADR).
- Commencez par la page d'architecture d'Atlas Bank : elle montre le contrat de dépendances sur lequel s'appuie la première partie.
Pour votre agent de code
Les règles des huit parties dans un seul fichier, écrites pour un agent. Collez-les dans votre AGENTS.md, enregistrez-les comme , ou indiquez le fichier à votre agent. Chaque partie a aussi son propre fichier à la fin de l'article.
# Frontend system design rules for enterprise apps
Source: https://tt-tech.io/series/frontend-system-design
For Angular and React frontends. All eight parts of the series in one file. Add to AGENTS.md or save as a skill. Your project's own rules win where they differ.
## Frontend architecture rules for enterprise apps
Source: https://tt-tech.io/blog/frontend-architecture-enterprise-boundaries
### Rules
1. Split code by type, not only by feature: `domain` (pure models and rules), `data-access` (the only code that talks to the backend), `ui` (inputs and outputs only), `feature` (wires the three) and `app` (shell and routes). Put new code in the layer it belongs to.
2. Keep the "may import" contract: `ui` never imports `data-access`, `domain` imports nothing, and nothing modern imports the legacy code.
3. Enforce boundaries in lint, not in a wiki. On Nx, tag every project and set which tags may depend on which (`onlyDependOnLibsWithTags`). Without Nx, use Sheriff.
4. When the boundary lint fails, let it fail: fix the import, or record the crossing. Never switch the rule off to make the change pass.
5. When a boundary must move, write it down in an Architectural Decision Record (ADR) with the rationale, and reference it in the pull request.
6. Treat the design system as a library under the same rules. Components read colour from tokens by role, never from a raw hex value.
7. Keep the legacy corner frozen and wrapped. No new code imports it. Dependencies point one way, from old to new, so the old code stays deletable.
8. Treat monorepo or polyrepo as an organisational choice. One repository with boundaries inside fits teams that share a design system, a domain model and one release train. Separate repositories fit products that ship on their own schedules and share almost nothing, or when strict repository-level access control is a hard requirement.
9. Do not introduce micro-frontends to fix a messy codebase. They fit when separate organisations must deploy independently into one shell. Fix the missing boundary first.
### Before you say done
- Run the project's boundary lint (Nx module boundaries or Sheriff) and fix every new error.
- Search the diff for imports from `ui` into `data-access`, from modern code into legacy code, and for raw colour values in stylesheets.
- List every boundary crossing in the pull request with its ADR.
## State rules for enterprise apps
Source: https://tt-tech.io/blog/enterprise-frontend-state-ownership
### Rules
1. Before you add state, name its kind: server data, UI state, form state, URL state or session state. Store it as that kind.
2. Put anything a link, a bookmark, a support ticket or the back button must restore in the URL: filters, tabs, the selected record. In Angular, bind route and query parameters to inputs with `withComponentInputBinding`, and expect a missing parameter to arrive as `undefined`.
3. Server data belongs to the backend. Never copy it into a component. Read it from the store of its domain.
4. Give each domain its own store (accounts, cards, requests), owned by the team that owns the domain. Never add a single global store for everything.
5. Keep global only what is cross-cutting by nature: the session, the permissions, maybe feature flags. Do not cache roles in a store nobody refreshes.
6. Keep UI state (which panel is open) in the component that owns it.
7. Derive, do not duplicate. If a value can be calculated from state, use `computed` in Angular or calculate it during render in React. Never store it as a second copy.
8. Never use `effect` to propagate state changes. Use `computed` instead.
9. Keep a store's writable state private. Expose read-only signals with `asReadonly()` and derived values, so the store is the only owner.
10. In long regulated forms, keep drafts, mirror the server's validation and track dirty state. The server still validates; the client check is for the person typing.
11. Use Signal Forms in new code built with signals. Keep reactive forms in an existing app that uses them. Do not migrate forms for fashion.
12. Use signals for state (a value now) and RxJS for events over time (keystrokes, websockets, retries). Bridge them with `toSignal`.
### Before you say done
- For every new piece of state in the diff, list its kind and its owner in the pull request.
- Search the diff for `effect(` calls that set other state, and for stored values that could be a `computed` or a calculation during render.
- For every new filter, tab or selection, confirm it lives in the URL, or say why not.
## Networking and caching rules for enterprise apps
Source: https://tt-tech.io/blog/enterprise-frontend-networking-caching
### Rules
1. Components never fetch. All backend calls go through one data-access layer per domain. No `fetch(` or `HttpClient` outside it.
2. Add a backend for frontend (BFF), owned by the frontend team, only when the screens need a shape the domain APIs do not have, or to keep secrets out of the browser.
3. Generate API types from the OpenAPI document. Never write them by hand. Treat a field as always present only when the schema's `required` array names it.
4. Cancel requests the user left: `httpResource` in Angular, `AbortController` with `fetch`. An older response must never overwrite a newer one.
5. Dedupe requests already in flight, so several widgets asking for the same data cause one request.
6. Give every call a timeout, so a slow backend becomes an error message, not an endless spinner.
7. The code that performs a write owns the cache of that data. After the write, refetch or apply the server's answer in the owning store. Never keep a private copy in a widget.
8. Set `Cache-Control` on purpose: `no-store` for responses that must not be reused (an account balance), `private` for user-personalised content, never personalised content in a shared cache, and a long `max-age` plus `immutable` for hashed build assets.
9. Retry reads only. Never retry a `POST` or `PATCH` automatically unless it carries an idempotency key that the server deduplicates.
10. On `409 Conflict`, show the conflict to the user and reload. Do not retry.
11. Put cross-cutting concerns in interceptors: auth headers, timeouts, read retries, logging, and a correlation id per request so server logs can be matched to what the user clicked.
12. On mobile, where the connection is often slow or gone, cache first: show the cached copy at once and refresh it in the background (stale-while-revalidate), with a time to live per entry. The write still clears what it makes wrong.
### Before you say done
- Search the diff for `fetch(` and `HttpClient` outside the data-access layer. Move each hit behind the boundary.
- For every write in the diff, list in the pull request which cached data it makes wrong and where it is refreshed.
- Check every retry in the diff: it is on a read, or the write carries an idempotency key.
- For every new or changed sensitive response, state its `Cache-Control` header.
## Rendering rules for enterprise apps
Source: https://tt-tech.io/blog/enterprise-frontend-rendering-csr-ssr
### Rules
1. Decide rendering per page, not per app. The line is the login: pages behind it use client-side rendering (CSR) by default.
2. Do not add server-side rendering (SSR) to dashboards, back office or other pages behind a login by default. It adds a server to run, patch and audit.
3. For public pages, prerender what is the same for everyone and server-render what changes per request. In Angular, set it per route with `RenderMode.Client`, `RenderMode.Prerender` and `RenderMode.Server`.
4. Code that runs under SSR must not depend on browser APIs. Keep hydration on with `provideClientHydration()`, so Angular reuses the server DOM instead of re-rendering it.
5. Use incremental hydration (`@defer` with `hydrate` triggers such as idle, viewport or interaction) for a heavy widget on a public page. It is not a reason to add SSR.
6. Put heavy widgets below the fold (charts, rich-text editors, PDF viewers) in `@defer`, for example `@defer (on viewport)`.
7. Deferred dependencies must be standalone. Code that still lives in an `NgModule` cannot go in `@defer`; load it through a lazy route instead.
8. Split lazy routes along team lines: one feature library, one route, one chunk. Load primary landing pages eagerly, as Angular recommends, and avoid deeply nested lazy loading.
9. Never rely on Zone.js to notice a change. Trigger rendering through a signal read in the template, an input, a template listener, or `markForCheck()`.
10. Treat every React Server Function (`'use server'`) as a public endpoint. Its arguments are client-controlled: validate them and check that the logged-in user may perform the action.
### Before you say done
- For every new or changed route, state whether it is behind the login and which render mode it uses.
- When you add or move a `@defer` block or a lazy route, run the production build and report the initial bundle size before and after.
- Search changed components for plain fields the template reads and code mutates. Make them signals, or name the notification that renders them.
- Search the diff for `'use server'`. For each function, name where it validates its arguments and checks the user.
## Frontend performance rules for enterprise apps
Source: https://tt-tech.io/blog/frontend-performance-enterprise-budgets
### Rules
1. Keep a `budgets` section in the build configuration (`angular.json` or `project.json`) with `maximumWarning` and `maximumError`. Start with `initial` and `anyComponentStyle`. Add per-bundle budgets when a lazy feature starts to grow.
2. When there is no budget, or the error limit is far above today's size, set it just above the current initial bundle.
3. Load each feature through its own lazy route. Code that belongs to another screen goes in a lazy route; a heavy widget below the fold on the same screen (a chart, an editor) goes in `@defer`. In React, use `lazy` with a `<Suspense>` fallback.
4. Track every `@for` by a stable id (`track item.id`), never by object reference. Give React lists rendered with `.map()` a stable key in the same way.
5. For long lists, paginate on the server when users search and filter. Use virtual scroll (`<cdk-virtual-scroll-viewport>`) when they really scan a long list on one page. Virtual scroll fixes rendering, not downloading.
6. In image-heavy Angular screens, use `NgOptimizedImage` (`<img ngSrc>`, not `<img src>`) with a width and height, and `sizes` for the `srcset`. Mark the main image of the page `priority`.
7. Judge Core Web Vitals at the 75th percentile of page loads, mobile and desktop apart, never by average. Good is LCP within 2.5 seconds, INP at 200 milliseconds or less, CLS at 0.1 or less.
8. On data-heavy screens (filters, sorting, opening a row), watch INP first.
9. Use lab data (Lighthouse in CI) to catch regressions and field data from real users to decide what to fix. A run on a developer machine is not what users get.
10. Add `preload`, `prefetch` or `preconnect` only when a measurement shows a resource arrives late, not by default.
### Before you say done
- Run the production build. It must pass the budgets. Report any new budget warning and the initial bundle size before and after.
- Search the diff for `@for` blocks that track by object or `$index`, and for `.map()` lists without a stable key.
- Search the diff for `<img src` in Angular templates, and for heavy libraries imported outside a lazy route or `@defer`.
## Frontend security rules for enterprise apps
Source: https://tt-tech.io/blog/frontend-security-enterprise-untrusted-client
### Rules
1. The browser is untrusted. Every access rule and business limit is enforced on the server. A check in a component is only for the user's screen.
2. When you add a client-side check (a limit, a role, a permission), add or find the same check on the server in the same change. If there is none, report it as a finding.
3. Take roles and permissions from the server's answer. Never decide what the user may do by decoding a token in the client.
4. Route guards are for navigation, not protection. Put `canActivateChild` on the parent route of an area, use `canMatch` for role-based variants of a path, and still expect the server to refuse.
5. Hide actions a role never has. Disable, with the reason shown, actions the user could have later (a limit, a missing signature).
6. Keep the sanitizer on. Never call `bypassSecurityTrust*` (Angular) or use `dangerouslySetInnerHTML` (React) on data from a user or an API. If some markup is wanted, map an allowed subset to elements in the template.
7. Never store session ids or tokens in `localStorage` or `sessionStorage`. Use an `httpOnly` cookie session through a backend for frontend, or a short-lived token in memory when the API is shared with other clients.
8. With cookie sessions, keep CSRF protection on: in Angular, `HttpClient` sends the `XSRF-TOKEN` cookie back as an `X-XSRF-TOKEN` header, and the server must check it.
9. No secrets in client code: no API key, token or password in the frontend, not even temporarily. Everything shipped to the browser is public.
10. The browser never calls a third-party service directly. The backend holds the key and makes the call.
11. Treat the Content Security Policy as a second layer: nonces when a server renders each response, `autoCsp` hashes when the app is static files. Do not loosen it to make a script run.
12. Never log tokens, session ids, passwords, personal data or bank and card data from the client. Mask them or leave them out. Send an interaction id with each request instead, so server logs can be matched.
### Before you say done
- Search the diff for `bypassSecurityTrust`, `dangerouslySetInnerHTML`, `localStorage` and strings that look like keys. Remove each hit or explain it in the pull request.
- For every client-side rule in the diff, name the server code that enforces it.
- List what you checked and what you could not check.
## Accessibility rules for enterprise apps
Source: https://tt-tech.io/blog/enterprise-frontend-accessibility-design-system
### Rules
1. Target WCAG 2.2 level AA.
2. Use native HTML elements first (`<label>`, `<dialog>`, native form controls). Use ARIA only for a widget HTML does not have.
3. When you add an ARIA role, implement in script the keyboard and behaviour the browser would give the native element. A role is a promise you keep in code.
4. Every modal dialog: focus moves inside on open, Tab stays inside (`cdkTrapFocus` in Angular), Escape closes, and on close focus returns to where it was before.
5. On a route change in a single-page app, move focus (in Angular, on `NavigationEnd`). Never let focus fall back to `body`.
6. In forms, give every field a real `<label>`, show field errors with `role="alert"`, and on long forms add an error summary that links to each field.
7. Announce something that happened elsewhere on the page ("Transfer sent") through a live region, `LiveAnnouncer` in Angular.
8. Submissions of financial data need a step where the user reviews and corrects before the submission is final (WCAG 3.3.4).
9. Fix accessibility in the shared design-system component, not in each screen. Focus rings, contrast and keyboard behaviour are decided there once.
10. For combobox, date picker and data grid, adopt a tested building block (Angular CDK, Angular Aria, or a mature React library chosen for its keyboard support). Build only what is specific to the domain.
11. Never add an accessibility overlay script as a fix. It does not fix the components underneath.
12. Run automated accessibility checks in CI, and never report a flow as accessible from automated checks alone. Login, payment and signature also need a manual keyboard and screen reader pass.
### Before you say done
- Run the project's accessibility lint (for example angular-eslint's template accessibility rules) and fix every new error.
- Search the diff for form error messages without `role="alert"` or a live region, and for `role=` attributes. For each role, name the code that implements its keyboard behaviour.
- For every new or changed dialog, list in the pull request where the code handles focus in, Tab, Escape and focus return.
- List the manual keyboard and screen reader checks still needed.
## Internationalisation and RTL rules for enterprise apps
Source: https://tt-tech.io/blog/arabic-french-mad-i18n-rtl-enterprise
### Rules
1. Choose build-time translation (Angular i18n, one build per locale) when the locale is fixed per user and the texts ship with the code. Choose a runtime library when users switch language often or texts come from a CMS.
2. Set `dir` explicitly on the root `html` element. Use the `dir` attribute instead of the CSS `direction` property where possible.
3. Use logical CSS properties: `margin-inline-start`, `padding-inline-end`, `border-inline-start`, `text-align: start`. Never add `margin-left`, `padding-right` or `text-align: left` in new code.
4. Mirror icons that show direction, such as back and forward arrows, in RTL. Do not mirror a play button or a logo.
5. Wrap text whose direction you do not control (names, user input, external data) in `<bdi>` or give it `dir="auto"`.
6. Mark values that are always left to right, such as a RIB or an IBAN, with `dir="ltr"`.
7. Format money through one function. Use `Intl.NumberFormat` for display across many locales; use a fixed, tested formatter when the format is a business decision.
8. Never assert an exact `Intl` output string in a test. It depends on the locale data the browser or Node version ships.
9. Build plurals and variable messages with ICU messages. Never concatenate translated strings.
10. Run design tokens, components and visual tests in RTL as well as LTR.
11. Use a font that renders Arabic well, not the Latin font's fallback. Leave room for translated text, which is often longer than English.
12. Let the user choose the language and remember the choice.
### Before you say done
- Search the diff for `margin-left`, `margin-right`, `padding-left`, `padding-right`, `text-align: left` and `text-align: right`. Replace each with a logical property or explain it.
- Search the diff for money formatting (`Intl.NumberFormat`, `toFixed`, currency codes) outside the shared formatter.
- Search the diff for string concatenation in translated text, and for names, user input or external values rendered without `<bdi>`, `dir="auto"` or `dir="ltr"`.Sources
- AngularJS, Version Support Status, documentation
- OWASP, A01 Broken Access Control - OWASP Top 10:2025, 2025
- EUR-Lex, Directive (EU) 2019/882 on the accessibility requirements for products and services, 2019
- MDN, CSS logical properties and values