Skip to content
12 min read

How to Build a Scalable Angular Project Structure (Best Practices)

How I structure Angular projects for scale — feature-first folders, standalone components, lazy loading, and service layers that actually stay clean.

AngularArchitectureFrontendBest Practices

I've inherited enough poorly structured Angular codebases to know that the architecture decisions made in week one compound for years. An app that starts with a flat component folder and services scattered everywhere doesn't stay small — it grows, and every new engineer adds their own interpretation of where things belong. By month six you have a 30,000-line codebase with no consistent pattern and a team that dreads touching unfamiliar areas.

I've also built Angular apps from scratch that are still clean after three years and four engineers. The difference wasn't talent — it was the structure decisions made before the first feature shipped. This post covers the ones that actually matter.

Why structure matters more in Angular than in most frameworks

Angular is opinionated about the primitives — components, services, directives, pipes — but it doesn't enforce how you organize them across a real project. That freedom is a trap for new teams. React apps suffer from the same problem, but Angular apps tend to grow larger and live longer, which means the structural debt compounds harder.

The goal of a good Angular project structure is simple: a developer who has never seen the codebase should be able to open the folder tree and find the code they need in under 30 seconds. If that's not true for your current app, you have a structure problem.

The folder structure mistake most teams make

The most common mistake is organizing by type — all components in one folder, all services in another, all models in a third. It looks clean when the app has ten files. By the time it has three hundred, finding the service that belongs to a specific feature requires cross-referencing three separate directories.

The fix is organizing by feature instead. Each feature is a self-contained folder with its own components, services, models, and routes. You open one folder and find everything the feature touches. This is sometimes called a domain-driven structure, and it's what every large Angular codebase I've seen evolve toward — usually after someone has already paid the cost of not doing it early.

A feature-first folder layout that scales

Here's the structure I use as a starting point on every new Angular project:

  • `src/app/core/` — singleton services that live for the life of the app: auth, HTTP interceptors, error handling, analytics. Imported once in AppComponent or via provideX() calls. Nothing in core/ should be feature-specific.
  • `src/app/shared/` — components, directives, and pipes used by more than one feature. A DatePipe wrapper, a LoadingSpinnerComponent, a ConfirmDialogComponent. The rule: if only one feature uses it, it lives in that feature folder, not here.
  • `src/app/features/` — one subfolder per domain: features/dashboard/, features/appointments/, features/billing/. Each feature folder owns its routes, its components, its services, and its models.
  • `src/app/layout/` — shell components: NavbarComponent, SidebarComponent, FooterComponent. These are not features — they are infrastructure for rendering features.
  • `src/environments/` — environment files (environment.ts, environment.prod.ts). No API keys or secrets — only feature flags, base URLs, and config switches.

Inside a feature folder like features/appointments/, the structure mirrors the same pattern at a smaller scale: components/, services/, models/, appointments.routes.ts. The feature is a world unto itself. You can move it, delete it, or hand it to a different team without touching anything outside the folder.

Standalone components in 2026

If you are starting a new Angular project today, use standalone components everywhere. NgModules are effectively legacy — Angular's tooling, documentation, and community have moved on. Standalone components declare their own imports, which means no module boundary hunting when you add a new dependency.

The shift also changes how you think about shared/. With NgModules, shared components were bundled into a SharedModule that you imported into every feature module. With standalone components, each component imports exactly what it needs. The SharedModule god-object goes away, and tree-shaking actually works.

  • Don't create a `SharedModule` — it becomes a dumping ground and breaks tree-shaking. Export standalone components individually instead.
  • Use `provideX()` functions instead of forRoot() patterns for services. Cleaner, more explicit, and easier to test.
  • Lazy-load feature routes as route-level standalone components — Angular's router handles this cleanly without any module ceremony.

Core services: the stuff that has to exist once

The services in core/ aren't numerous — but they're the ones every other service in the app depends on. Getting them wrong early means a refactor that touches everywhere.

AuthService and HTTP interceptors

Your AuthService belongs in core/. It manages the session, refreshes tokens, and exposes observables other services can subscribe to. Every HTTP interceptor — auth header injection, error handling, loading state — lives next to it in core/interceptors/. Register them once in app.config.ts with provideHttpClient(withInterceptors([...])) and never touch that config again.

Error handling

A global error handler in core/ that catches unhandled errors and sends them to your monitoring service (Sentry, Datadog, whatever) is non-negotiable. Don't scatter try/catch blocks through your feature services hoping you'll catch everything. One ErrorHandler override, registered in app.config.ts, and you have a single place to evolve your error strategy.

Lazy loading: the strategy that determines your bundle size

Every feature in features/ should be lazy-loaded. Angular's router makes this straightforward — point a route to () => import('./features/appointments/appointments.routes') and the browser won't download that code until the user navigates there. For a mid-sized app, this typically cuts the initial bundle by 40–60%.

The mistake I see most often is lazy-loading at the wrong granularity — people create one lazy chunk for the entire app, or they forget to lazy-load at all and wonder why their initial load is slow. The rule I follow: every top-level route should be a lazy boundary. Sub-routes within a feature can be eager.

  • Use `loadChildren` for feature routes — not loadComponent. A single component is rarely the right lazy boundary.
  • Add route-level preloading with PreloadAllModules or a custom preloading strategy once the initial bundle is healthy. This warms the chunks the user is likely to visit next.
  • Check your bundle with `ng build --stats-json` and open it in webpack-bundle-analyzer. Do it before launch, not after a performance complaint.
  • `deferrable views` for heavy components — Angular 17+ lets you defer a slow component within a page with @defer. Use it for charts, rich text editors, and third-party widgets that add weight.

State management: picking the right tool for the job

The default answer in Angular interviews is NgRx. It's the right answer for maybe a third of the apps I've seen it in. Here's how I actually decide.

Start with signals

For most new Angular projects in 2026, start with signals. A signal() in a service replaces the BehaviorSubject pattern most teams default to, with simpler syntax and better change detection integration. Component state that doesn't need to be shared doesn't even need a service — it lives in the component as a signal.

When to add NgRx

NgRx earns its weight when you have complex shared state — multiple components reading the same data slice, optimistic updates that need rollback, or server-side event streams updating multiple parts of the UI simultaneously. Add it when the friction from managing that state manually becomes more painful than the NgRx boilerplate. Don't add it to a new project by default. I've shipped several large enterprise Angular apps without full NgRx — NgRx Component Store is usually enough and cuts the boilerplate by 60%.

The service with a signal pattern

The pattern that covers 80% of state needs: a service that holds a signal() (or WritableSignal), exposes a computed() for derived values, and provides methods that update the signal. Components inject the service and read the signal. No RxJS, no BehaviorSubject, no piping. It's testable, it's readable, and it doesn't require a PhD to understand six months later.

The service layer: one responsibility per service

The worst Angular codebases I've inherited have services that do everything: HTTP calls, business logic, UI state, local storage, and the occasional DOM manipulation. When a service does everything, testing any one thing requires setting up the world.

The structure I enforce: one ApiService per feature that handles HTTP (raw request/response, nothing else), and one [Feature]StateService that handles business logic and state. The component talks to the state service. The state service calls the API service. Nothing crosses those boundaries.

  • `AppointmentsApiService`GET /appointments, POST /appointments, PATCH /appointments/:id. Returns typed Observables or Promises. No business logic.
  • `AppointmentsStateService` — holds signals for the appointments list, loading state, and selected appointment. Calls AppointmentsApiService and handles error mapping.
  • `AppointmentListComponent` — injects AppointmentsStateService, reads signals, calls methods. Zero direct HTTP calls.

Barrel files: use them carefully

Barrel files (index.ts that re-export everything from a folder) look elegant but have a real cost. Angular's build tooling in 2026 handles tree-shaking well, but a barrel that re-exports 40 components from a shared folder will force the bundler to evaluate all 40 even when you only need one.

My rule: use barrel files at feature boundaries only — one index.ts that exports the public API of a feature. Never use them inside a feature to aggregate sub-folders. Direct imports within a feature are always cleaner and build faster.

Path aliases: eliminate the `../../..` problem

Set up TypeScript path aliases in tsconfig.json from day one. Relative imports like ../../../../core/auth/auth.service break when you move a file and obscure the actual dependency structure. Aliases like @core/auth, @shared/components, and @features/appointments are refactoring-proof and tell the reader the import's origin at a glance.

  • Add path mappings in tsconfig.json under compilerOptions.paths.
  • Map @core/* to src/app/core/*, @shared/* to src/app/shared/*, @features/* to src/app/features/*.
  • Configure the same aliases in angular.json under projects.[name].architect.build.options.tsConfig — the CLI needs to know about them for the dev server.
  • Enforce the pattern with an ESLint rule (no-relative-import-paths or a custom rule) so new engineers don't drift back to relative paths.

Testing structure that actually gets used

Co-locate tests next to the files they test — appointments.service.ts and appointments.service.spec.ts in the same folder. Separate __tests__ directories create friction that lowers the test rate. When tests live next to the code, engineers write them without thinking about where they go.

For component tests, prefer testing behaviour over implementation. Test what renders on screen and what happens when the user interacts, not the internal state of a class. Angular Testing Library is worth adding to every project — it pushes the testing style in the right direction without much ceremony.

Environment config without the leaks

Angular's environment.ts files are committed to source control, which means they're the wrong place for anything secret. Use them only for non-secret config: feature flags, base API URLs (not tokens), and environment labels. Secrets belong in CI/CD environment variables, injected at build or deploy time.

A pattern I've standardised on: an AppConfigService in core/ that reads environment.ts and exposes a typed config object. Components and services inject AppConfigService, not environment directly. When you need to mock config in tests, you mock the service — not the file.

If you're still deciding between Angular and React for the project itself, I covered the Angular vs React choice in detail — including how signals, server components, and team scale factor in.

A checklist before your first PR

The decisions that cost the least to make before any code exists are the ones that cost the most to change six months in. These are the ones I enforce on every greenfield Angular project.

  • Feature-first folder structure in place before writing the first feature component.
  • Standalone components from the start — no NgModules.
  • Path aliases configured in tsconfig.json and angular.json.
  • Lazy-loaded routes for every top-level feature.
  • Core and shared boundaries defined — write the rule down, not just in your head.
  • One `ApiService` and one `StateService` per feature — agree on naming before the first service is created.
  • Signals as the default state primitive — add NgRx only when signals genuinely stop scaling.
  • ESLint rules that enforce the structure — auto-fixable rules beat code review comments that say the same thing every week.

FAQ: Angular project structure

Should I use NgModules or standalone components in a new Angular project?+
Standalone components. NgModules are still supported but Angular's own documentation, schematics, and community have moved to standalone. New projects that start with NgModules will spend time migrating away from them within two years. Standalone components also simplify lazy loading, testing, and tree-shaking.
What is the best state management solution for Angular in 2026?+
Start with signals and a service-per-feature pattern. This covers the vast majority of real-world Angular apps without any external library. Add NgRx Component Store when a feature's state gets genuinely complex. Add full NgRx only when you need Redux-style time-travel debugging, cross-feature state coordination, or a large team that needs strict action/reducer discipline.
How should I organise shared components in an Angular project?+
In src/app/shared/, but with a strict rule: a component only goes in shared/ when it is used by at least two different features. If a component is used by one feature only, it lives in that feature's folder. This prevents shared/ from becoming a dumping ground for everything.
How many components should a feature folder have before I split it?+
There's no fixed number — I've had feature folders with two components and ones with twenty. The trigger to split is organisational, not numerical: when two sub-teams are owning different parts of the feature and stepping on each other's PRs, it's time to break the feature into sub-features. A feature folder with 20 well-organised components and clear naming is better than an artificial split that creates ambiguous boundaries.
Should I use barrel files (index.ts) in Angular?+
Use them at feature boundaries to define the public API of a feature, and nowhere else. A barrel that re-exports everything inside a feature folder creates bundle weight and slows incremental builds. Direct imports within a feature folder are always the right choice.
How do I enforce project structure conventions across a team?+
ESLint rules are the most durable enforcement mechanism — they catch violations in CI before they're merged. Write down the structure rules in a short architecture decision record (a one-page doc is fine) and link it from the README. Code review can catch the rest, but if you're leaving the same comment more than twice, write an ESLint rule instead.
When should I add lazy loading in Angular?+
From the first feature. Retroactively adding lazy loading to an Angular app that wasn't built for it is painful — routes are often too entangled with shared state and imports. The cost of setting up lazy routes on day one is thirty minutes. The cost of adding it later is a week of careful refactoring.
What is the difference between core and shared in Angular?+
core/ holds singleton services and infrastructure that is instantiated once for the life of the app: auth, HTTP interceptors, error handling, analytics. shared/ holds reusable UI components, directives, and pipes that are stateless and used across multiple features. The clearest test: if removing it would break the app's ability to run at all, it belongs in core/. If it's a UI building block, it belongs in shared/.

Building an Angular app and want a second opinion?

If you're starting a new Angular project or cleaning up one that has grown beyond its original structure, I'm available for architecture reviews and short engagements. I've built and scaled Angular apps in telehealth, ed-tech, and enterprise platforms — and I've done the painful work of inheriting ones that weren't structured for growth.

I take on Angular architecture reviews and short engagements — telehealth, ed-tech, and enterprise. If you are starting fresh or cleaning up a codebase that has outgrown its structure, take a look at the work and reach out.