Back to Blog
Angular DevelopmentJanuary 12, 202610 min read

Structuring a Large-Scale Angular Application

DR

Doug Ribeiro

10 min read

At GFT in Brazil, we built real-time dashboards for investment firms on a codebase that started with a shared module. Everything that was not a feature component went into SharedModule. Before long, SharedModule imported and exported dozens of things, and nobody could tell you what belonged there versus in a feature. Untangling it cost us real sprint time. At Wonderlabs, we went the other direction and put too much in feature modules, including things that several features needed. We ended up with near-duplicate components. The structure I use now is the result of those two failures. One scope note before the details: the loan application platform I worked on at BMO was split into micro-frontends, which is a different problem with its own rules. This post is about structuring a single Angular application, like the standalone products at GFT and Wonderlabs. The folder and feature names in the examples are illustrative, not taken from any employer's codebase.

The Three-Layer Model

Core, Shared, Features. These three layers have different rules and different purposes. Getting the rules right is what makes the structure scale.

  • Core: singleton services, HTTP interceptors, auth guards, app-level state, layout shell. Instantiated once. Never imported by Shared, and never re-provided by Features.
  • Shared: presentational components, pipes, directives, and utilities with no knowledge of business domain. Reusable across any project. No services with state.
  • Features: vertical slices of business functionality. Each feature owns its components, services, state, models, and routes. Features do not import from each other directly.
Feature ModulesCan import from Shared and Core. Never imported directly by other featu… 1 Shared LayerReusable components, pipes, directives. No feature-specific logic. 2 Core LayerSingleton services, interceptors, guards. Provided once, at the root. 3
text
src/app/
  core/
    auth/
      auth.service.ts
      auth.guard.ts
      auth.interceptor.ts
    http/
      api.service.ts
      error.interceptor.ts
      loading.interceptor.ts
    layout/
      shell/
      header/
      sidebar/
    store/              # Global NgRx state (app-level only)
      app.state.ts
      app.effects.ts
  shared/
    components/
      button/
      data-table/
      modal/
      form-field/
    pipes/
      currency-format.pipe.ts
      date-locale.pipe.ts
    directives/
      tooltip.directive.ts
  features/
    applications/
    lending/
    profile/

Feature Folder Structure

Each feature is a self-contained vertical slice. A developer who has never seen the codebase should be able to open the applications folder and understand everything about the applications feature. The structure is consistent across all features.

text
features/
  applications/
    components/
      application-list/
        application-list.component.ts
        application-list.component.html
        application-list.component.scss
        application-list.component.spec.ts
      application-detail/
      application-form/
    services/
      applications.service.ts
      applications-api.service.ts
    store/
      applications.store.ts    # NgRx Signal Store
    models/
      application.model.ts
      application-status.enum.ts
    applications.routes.ts
    index.ts                   # Public API for cross-feature imports

The index.ts at the feature root is a deliberate public API boundary. It exports only what other features or core legitimately need. Everything else is internal to the feature. When you need to refactor internals, you know the blast radius.

Barrel Files: Use Them at Boundaries, Nowhere Else

Barrel files at every folder level sound tidy in theory. In practice, they create circular dependency chains that TypeScript resolves in non-obvious ways, and they slow down compilation on large projects because the TypeScript compiler has to resolve more files. My rule: one barrel at the feature root for the public API, and nothing else.

typescript
// features/applications/index.ts
// Only export what genuinely crosses the feature boundary
export { ApplicationSummaryComponent } from './components/application-summary/application-summary.component';
export { Application } from './models/application.model';
export { ApplicationStatus } from './models/application-status.enum';
export { ApplicationsService } from './services/applications.service';

// Consuming from another feature: use the barrel
import { Application } from '@features/applications';

// Do NOT do this from outside the feature:
import { ApplicationFormComponent } from '@features/applications/components/application-form/application-form.component';

Path Aliases

Configure tsconfig.json path aliases on day one. Relative imports that climb more than two levels up the directory tree are a sign the structure needs fixing, but until you fix the structure, relative imports make refactoring painful. Aliases are also self-documenting: @core/auth reads better than ../../../core/auth.

json
{
  "compilerOptions": {
    "paths": {
      "@core/*": ["src/app/core/*"],
      "@shared/*": ["src/app/shared/*"],
      "@features/*": ["src/app/features/*"],
      "@env/*": ["src/environments/*"]
    }
  }
}

Enforcing Boundaries with ESLint

Structure without enforcement is decoration. Developers under deadline pressure will import directly from another feature's internals if the path works. The @nx/enforce-module-boundaries ESLint rule prevents this, but it requires an Nx workspace with a project graph and tags. For non-Nx projects, use eslint-plugin-boundaries instead. It gives you the same enforcement with glob-based rules that do not depend on Nx tooling.

The boundaries I enforce: features cannot import directly from other features (must go through index.ts), shared cannot import from features or core, core cannot import from shared components. Violations fail lint in CI before the PR is reviewed. The wiring is unremarkable: the boundary rules are set to error rather than warn, and the pipeline runs ng lint as its own step ahead of the build. A violated boundary makes ESLint exit non-zero, the step goes red, and the pull request is blocked by branch protection. No custom reporter, no extra plugin, and nothing that only works on one CI provider.

What Breaks Without This Structure

  • A mega-shared module that balloons until nobody knows what is in it: split shared into ui-components, utilities, and models
  • Components named after their visual role instead of their domain: TransactionCard, not CardWithBadge
  • Services provided in features that get instantiated multiple times because they are not root-provided
  • Route configuration scattered across app.module or app.config instead of colocated with the feature
  • Test files in a separate tests folder at the root instead of colocated with the file they test

What I Would Do Differently Starting Today

With standalone components as the default (for new projects since Angular 17, and in the decorator itself since Angular 19), the NgModule layer is gone. The three-layer model holds, but the shared layer is now just standalone components, pipes, and directives, not a module. Feature routes are just route files, not NgModules. The structure is the same, the boilerplate is less.

I would also configure the ESLint boundary rules on day one, before there is any code to violate them. Retrofitting boundary enforcement onto a codebase that never had it means fixing hundreds of import violations before the rule can go to error. Configure it on day one and it costs nothing.

The test of a structure is whether a developer who has never seen the codebase can navigate to the right place in under two minutes. If they cannot, the structure is not paying its way.

The three-layer model with enforced boundaries, path aliases, and colocated tests has held up on every project I have applied it to. It is not exciting. It is just consistent, and consistency is what makes large Angular codebases maintainable as the team and feature count grow.

References

AngularArchitectureTypeScriptBest Practices