EthicsComputerCourses
Join training +91 90532 10052

Structuring Large Angular Apps

Angular · Lesson 28 of 41 Advanced 8 min read Updated

A tutorial app fits in one folder. A real product does not. An HR portal for a company with offices in Pune and Kochi might have leave management, payroll, an employee directory, reports and an admin area, built by three teams over several years. If every file sits in src/app, nobody can find anything, every change touches everything, and the first screen downloads code for pages the user never opens.

In this lesson you will learn a folder structure that scales, the rules that keep it clean, and how to share code between several apps with libraries in an Angular CLI workspace.

Organise by feature, not by file type

Beginners often create folders such as components/, services/ and models/. That works for ten files and fails for five hundred: the code for one feature is spread across the whole tree. The Angular style guide recommends organising by feature area instead, so everything about leave lives together:

Text
src/app/
  core/                    app-wide, created once
    auth/                  auth-store.ts, auth.interceptor.ts, auth.guard.ts
    error/                 global-error-handler.ts, logger.ts
    layout/                shell.ts, top-bar.ts, side-nav.ts
  shared/                  reusable, knows nothing about features
    ui/                    button.ts, data-table.ts, empty-state.ts
    pipes/                 inr.pipe.ts
    util/                  dates.ts
  features/
    leave/
      leave.routes.ts      the only public entry point of the feature
      data-access/         leave-api.ts, leave-store.ts, leave.model.ts
      pages/               leave-list.ts, leave-apply.ts, leave-detail.ts
      ui/                  leave-card.ts, leave-status-badge.ts
    payroll/
      payroll.routes.ts
      ...
    directory/
      ...
  app.config.ts
  app.routes.ts
  app.ts
  • core holds things the whole app needs exactly once: authentication, interceptors, the error handler and the layout shell.
  • shared holds generic building blocks with no business knowledge. A DataTable can show leave requests or payslips; it does not know which.
  • features hold the business. Each feature has its own routes, data access (API services and stores), pages (components that the router shows, which inject stores and handle actions) and ui components (which only receive inputs and emit outputs).

Notice the file names: since Angular 20, the CLI and style guide drop suffixes such as .component.ts, so a component lives in leave-list.ts with the class LeaveList. Services, guards and interceptors often keep a descriptive suffix (auth.guard.ts) because it helps when searching.

The dependency rules

Structure only helps if everyone follows a few rules about who may import whom:

A file in...May import fromMust not import from
features/leavecore, shared, its own folderother features such as features/payroll
sharedother shared code, Angular, librariescore and any feature
coresharedfeatures

If leave needs something from payroll, that is a sign the code belongs in shared, or in a service both features talk to through the API. Features that do not know about each other can be changed, tested, lazy-loaded and even moved to another team without breaking the rest.

Step by step: wire up a feature

  1. Give the feature its own routes file. Feature-level services, such as a store that only leave pages need, go in the route's providers, so they are created when the user first opens the feature and are scoped to it:
TypeScript
// features/leave/leave.routes.ts
import { Routes } from '@angular/router';
import { LeaveStore } from './data-access/leave-store';

export const LEAVE_ROUTES: Routes = [
  {
    path: '',
    providers: [LeaveStore],
    children: [
      { path: '', loadComponent: () => import('./pages/leave-list').then((m) => m.LeaveList) },
      { path: 'apply', loadComponent: () => import('./pages/leave-apply').then((m) => m.LeaveApply) },
      { path: ':id', loadComponent: () => import('./pages/leave-detail').then((m) => m.LeaveDetail) },
    ],
  },
];
  1. Lazy-load every feature from the app routes, behind the shell layout and the auth guard:
TypeScript
// app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from './core/auth/auth.guard';
import { Shell } from './core/layout/shell';

export const routes: Routes = [
  { path: 'login', loadComponent: () => import('./core/auth/login-page').then((m) => m.LoginPage) },
  {
    path: '',
    component: Shell,
    canActivate: [authGuard],
    children: [
      { path: 'leave', loadChildren: () => import('./features/leave/leave.routes').then((m) => m.LEAVE_ROUTES) },
      { path: 'payroll', loadChildren: () => import('./features/payroll/payroll.routes').then((m) => m.PAYROLL_ROUTES) },
      { path: '', pathMatch: 'full', redirectTo: 'leave' },
    ],
  },
  { path: '**', loadComponent: () => import('./core/layout/not-found').then((m) => m.NotFound) },
];
  1. Add path aliases so imports stay short and do not break when files move. In tsconfig.json, under compilerOptions:
JSON
"paths": {
  "@core/*": ["./src/app/core/*"],
  "@shared/*": ["./src/app/shared/*"]
}

Now import { InrPipe } from '@shared/pipes/inr.pipe'; works from any depth, instead of '../../../shared/pipes/inr.pipe'.

  1. Make the rules automatic. People forget rules; tools do not. ESLint's built-in no-restricted-imports rule can stop shared code from importing features or core. In a flat ESLint config (eslint.config.js), add a block like this (ESLint itself is added to an Angular project with ng add angular-eslint):
JavaScript
{
  files: ['src/app/shared/**/*.ts'],
  rules: {
    'no-restricted-imports': [
      'error',
      {
        patterns: [
          { regex: '/features/', message: 'Shared code must not import from features.' },
          { regex: '^@core/', message: 'Shared code must not import from core.' },
        ],
      },
    ],
  },
},

A pull request that breaks the rule now fails ng lint in CI, long before review.

  1. Check the bundles with ng build. Each feature appears as its own lazy chunk, so a user who only applies for leave never downloads payroll code.

Libraries in a workspace

Sometimes code must be shared between several apps: an employee portal, a manager app and an admin console that use the same design components. The Angular CLI supports this with a workspace: one repository, one node_modules, several projects under projects/.

  1. Create an empty workspace and add an app and a library:
Terminal
ng new leavedesk --no-create-application
cd leavedesk
ng generate application hr-portal
ng generate library ui-kit

The library command prints, among other lines:

Output
CREATE projects/ui-kit/ng-package.json (161 bytes)
CREATE projects/ui-kit/package.json (222 bytes)
CREATE projects/ui-kit/src/public-api.ts (75 bytes)
CREATE projects/ui-kit/src/lib/ui-kit.ts (208 bytes)
UPDATE angular.json (2738 bytes)
UPDATE package.json (814 bytes)
UPDATE tsconfig.json (1149 bytes)
  1. Put shared components in projects/ui-kit/src/lib/ and export them from public-api.ts. This file is the library's public API: anything not exported there is private, so the team can refactor internals freely.
TypeScript
// projects/ui-kit/src/public-api.ts
export * from './lib/status-badge';
export * from './lib/data-table';
  1. Build the library, then import it by name in the app. The CLI added a path mapping "ui-kit": ["./dist/ui-kit"] to tsconfig.json, so the app uses the built output exactly as it would use a package from npm:
Terminal
ng build ui-kit
ng serve hr-portal
TypeScript
import { StatusBadge } from 'ui-kit';

While you work on the library, run ng build ui-kit --watch in a second terminal so the app picks up changes.

Large organisations often add a monorepo tool such as Nx on top of this idea. It adds a project graph, "only test what changed" commands for CI and lint rules for library boundaries. The structure and rules in this lesson apply with or without such a tool.

Common mistakes

  • Folders by type (components/, services/) in a large app. Group by feature.
  • Features importing each other, which creates hidden coupling and breaks lazy loading.
  • A "shared" folder full of business logic. If it knows what a leave request is, it belongs to a feature.
  • Exporting everything from a library. Keep public-api.ts small so you can change internals.
  • Forgetting to rebuild the library and then wondering why the app does not see your change.

Key points

  • Organise large apps into core (once per app), shared (generic, reusable) and features (business areas).
  • Features must not import each other; enforce the rules with ESLint in CI.
  • Each feature exposes one routes file, is lazy-loaded with loadChildren, and scopes its stores with route providers.
  • Path aliases in tsconfig.json keep imports short and stable.
  • A CLI workspace (ng new --no-create-application, ng generate application, ng generate library) shares code between apps through each library's public-api.ts.

Exercise

Plan the structure of a hostel management app for a college in Rohtak with three features: room allotment, mess menu and complaints. Write the folder tree, the app.routes.ts that lazy-loads all three behind an authGuard, and say where these files go: a RoomCard component that only shows a room, the ComplaintStore, an InrPipe, and the HTTP interceptor that adds the login token.

Show solution
Text
src/app/
  core/auth/          auth-store.ts, auth.guard.ts, auth.interceptor.ts
  core/layout/        shell.ts
  shared/pipes/       inr.pipe.ts
  features/rooms/     rooms.routes.ts, data-access/, pages/, ui/room-card.ts
  features/mess/      mess.routes.ts, data-access/, pages/
  features/complaints/complaints.routes.ts, data-access/complaint-store.ts, pages/
TypeScript
export const routes: Routes = [
  {
    path: '',
    component: Shell,
    canActivate: [authGuard],
    children: [
      { path: 'rooms', loadChildren: () => import('./features/rooms/rooms.routes').then((m) => m.ROOM_ROUTES) },
      { path: 'mess', loadChildren: () => import('./features/mess/mess.routes').then((m) => m.MESS_ROUTES) },
      { path: 'complaints', loadChildren: () => import('./features/complaints/complaints.routes').then((m) => m.COMPLAINT_ROUTES) },
      { path: '', pathMatch: 'full', redirectTo: 'rooms' },
    ],
  },
];

RoomCard only displays data, so it is a ui component inside the rooms feature. ComplaintStore belongs to the complaints feature's data-access folder and is provided in its route. InrPipe is generic, so it goes in shared. The interceptor is needed once for the whole app, so it lives in core/auth.

Quick Quiz

1. Where should a generic DataTable component that shows any list of rows live?

2. Which import breaks the dependency rules?

3. Why put a feature store in the route's providers?

4. What decides what a workspace library exposes to apps?

Learn Angular with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call