Frontend system design for enterprise apps: 8 decisions
Frontend system design for enterprise apps is judgement, not a glossary: eight areas, the few concepts that matter in each, and when to choose what.

An enterprise frontend outlives its framework, its first team and usually its first architect. That changes which concepts matter. The concepts were never the hard part. Knowing which ones to ignore was.
Most frontend system design content prepares you for an interview: design a news feed in 45 minutes, on a whiteboard, for nobody. Enterprise frontends fail in a different way. They do not fail on day one. They fail in year seven, when the decision nobody made on purpose is now in four hundred files.
I have spent more than twenty years in software, building enterprise applications. This series is what I would tell a team starting one, or inheriting one.
What makes a frontend "enterprise"
It is not the size of the . It is five things that small apps rarely have at the same time.
Time. The code lives longer than the framework. support ended in January 2022. The apps built on it did not end with it. I have been part of several migrations from AngularJS to , in different teams and roles. The lesson every time: the boundaries you draw in year one decide what you can delete in year ten.
Teams. Many teams in one codebase, often one . The lines between their code are the architecture, whether anyone drew them or not.
Rules. Banking and healthcare come with rules the frontend cannot wave away. is "only effective when implemented in trusted server-side code or serverless APIs", and broken access control keeps the top spot in the :2025. In the EU, the covers consumer banking services provided to consumers after 28 June 2025. For banks in scope, accessibility is a requirement there, not a nice-to-have.
Roles. The same screen looks different for a customer, an advisor and an auditor. Somebody has to decide where that logic lives.
Languages. For Morocco and the Gulf, Arabic is , French text often runs longer than English, and Moroccan money is MAD with its own format. A layout built with left and right breaks the day Arabic arrives. describe directions "relative to the content's writing direction, rather than a physical direction", and that one choice decides whether RTL is a small task or a rewrite.
In enterprise, the decision outlives the people who made it. Design for the next team.
The eight parts
Eight areas, and in each only the five to seven concepts that change a decision.
- Architecture. Layers, enforced boundaries, monorepo or not, mostly not, and the legacy corner you keep deletable.
- State. Who owns each piece of state and how long it lives. Server data, UI, forms, the URL.
- Networking and caching. One question, so one part: where does this data live, and when is it wrong?
- Rendering. Many logged-in enterprise apps do not need . When they do, and what it costs.
- Performance. Budgets that fail the build, measured on the devices your users really have.
- Security. The frontend is the untrusted part. What the client can protect, and what it only shows.
- Accessibility. A legal requirement, and the is where it is won or lost.
- Languages, RTL and money. Arabic, French and MAD from the first component, not as a later project.
Caching sits with networking because a cache without an owner is a bug with a delay. RTL gets its own part because for readers in Morocco and the Gulf it is not a sub-bullet of accessibility.
Start with part 1, architecture.
How each part works
Every part has the same shape:
- The few concepts that matter, each with one line on why.
- When to choose what. or , or SSR, monorepo or . The trade-off in plain words, Angular first, React next to it where the choice differs.
- What I left out, and why. A concept that sounds important but rarely changes a decision in an enterprise app.
- An example on Atlas Bank, the open demo app of the AI with Angular series: an imaginary Moroccan bank, built as an monorepo, with that
npm run lint:archenforces and two legacy corners (anNgModuletransfer screen and an AngularJS beneficiaries screen). The repo is public. Never client code.
A glossary tells you what a word means. Production tells you which words you can ignore.
On Monday
- Take the oldest screen in your app. For each of the eight areas, ask: was this decided on purpose, or did it happen by default?
- Write down the one you cannot answer. That is your first .
- Start with Atlas Bank's architecture page: it shows the dependency contract the first part builds on.
For your coding agent
The rules of all eight parts in one file, written for an agent. Paste them into your AGENTS.md, save them as a skill, or point your agent at the file. Each part also has its own file at the end of the 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, docs
- 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