In 2024, Angular keeps changing for better with ever increasing pace, but the big picture remains the same which makes architecture know-how timeless and well worth your time!

emoji_objects emoji_objects emoji_objects
Tomas Trajan

Tomas Trajan

@tomastrajan

Sep 10, 2024

15 min read

Top 10 Angular Architecture Mistakes You Really Want To Avoid
share

Learn how to prevent such scenario in your project (even though it looks kind of cool 😉)
Image by GPT4 | design by Tomas Trajan

The Angular ecosystem is experiencing a remarkable revival, and 2024 has pushed that momentum even further. The Angular team has shipped signal-based input / outputs / viewChild(ren) / contentChild(ren), a fresh control flow syntax, @defer for effortless component-level code splitting, and even a preview of zone-less change detection that landed in Angular 18 back in May.

These additions bring tangible benefits to the table. Developer experience, runtime performance, and overall code quality all see measurable improvements when these tools are adopted.

BUT

Yet, despite all of this progress, the fundamental way we organize our workspaces and applications hasn't really shifted. And honestly, that's a very good thing because…

Knowledge about Angular architecture is essentially timeless — and investing your time in it pays off!

The principles governing how we lay out and structure an Angular codebase have remained remarkably stable since the days of Angular 4, which introduced a production-ready Router with support for lazy route loading — a cornerstone of sound architecture that goes far beyond mere performance gains.

Sure, APIs have been adjusted over the years, but most of those modifications have touched the syntactic, lower-level details rather than reshaping how we architect our solutions.

Consider the arrival of standalone components and APIs, which made NgModules optional. What changed is that we now lazy load route configurations (or the root component of a feature) rather than entire feature modules.

Is that a meaningful difference? Absolutely!

But does it alter the blueprint of how we organize things at the higher architectural level? Not in the least!

Hopefully this sets the stage and has piqued your interest in Angular architecture, because this kind of expertise has been, remains, and will continue to be relevant and valuable. It enables you to steer your projects toward success and deliver value to yourself, your team, and your organization.

So, what are the most frequent architectural mistakes we see in Angular applications?

Going in without an architecture (or any plan at all)

There's a lot of talk about moving fast and breaking things — and that's generally a positive thing, since it lets us (in theory) adapt quickly to shifting demands and keeps our product relevant.

What often gets overlooked, though, is that if we want to sustain that same velocity a year into the project, we also need to prevent the codebase from turning into a tangled mess of interdependencies — or in more formal terms, a dependency graph riddled with circular references.

Yes, this is a dependency graph from a real project, all the lines are dependencies (imports between files) Yes, this is a dependency graph from a real project, all the lines are dependencies (imports between files)

Approaching a project without a defined architectural model typically produces the kind of situation pictured above. That transforms our initial mantra…

“move fast and break things ”

into something closer to

“try to change this one thing, break everything”

kind of reality 😅

One telltale sign that you're in this situation is that pervasive feeling of being trapped in a web — where fixing one issue sends you down a rabbit hole of touching half the codebase, stacking one conditional on top of another just to make it work one more time.

In short, architecture deserves deliberate thought so we can continue to "move fast, breaking small isolated pieces (that don't affect the system as a whole)" for the life of the project.

Ignoring the eager versus lazy split

In Angular — as in frontend development broadly — the goal is to minimize the amount of JavaScript that has to be fetched upfront, since that plays a major role in how quickly the app starts for users.

By the way, the bottleneck these days is usually NOT the network connection speed, but rather the processing power of lower-end CPUs that have to parse and run all that downloaded JavaScript.

So most Angular apps end up with concepts like a core (eagerly loaded) area and feature / page / view (lazily loaded) areas to acknowledge this fundamental reality of web development.

Having the concepts and folders, however, isn't sufficient. We also need to ensure that, over time, we don't inadvertently erode that separation.

A classic example: there's a feature-specific service managing feature-specific state, and at some point we decide it would be handy for a service in the eager core area to consume that state as well.

When that moment comes, it's all too easy to inject the feature service into a core service, with no one catching that eager/lazy boundary has been crossed (or it slips past in code review). The consequence is that the service — along with everything it pulls in — suddenly gets bundled into the eager payload.

This is damaging for both performance and architecture, since it establishes a dependency between parts that either should be independent or should follow a one-directional rule: feature may depend on core, but never the reverse!

Left unchecked, this is exactly how an app ends up in the sort of tangled dependency chart described under the first mistake at the top of this article!

Failing to lazy load ALL features

There's another frequent culprit that shows up even in projects that are generally well-organized: the eager/lazy split is mostly in place and the bulk of logic lives in lazy-loaded features, but somehow certain features get "overlooked"…

The most common ones I've encountered out in the wild are:

  • login / sign up flows
  • generic error pages / 404 page
  • the very first feature that was built (often the home or dashboard)

That last one is the most widespread and, at the same time, the most unfortunate — particularly because it's easy to see how it happens organically.

Picture this: you're building a brand new app and tackling the first batch of business requirements, which involves displaying some data.

There's no navigation structure yet, so you simply create components and compose them directly in templates, all the way up to the root AppComponent.

Then, of course, new requirements arrive. You add navigation, and the new stuff is properly implemented as lazy-loaded features. Unfortunately, there's rarely time (or budget, or willingness) to go back and refactor that original feature to be lazy as well.

Now you're stuck with at least two competing patterns (an eager feature alongside lazy features), and both isolation and performance take a hit.

The way to avoid this is to treat everything — including that first feature — as a lazy-loaded feature from day one! In other words…

Even a single-page (single-feature) application with no real navigation should have its first page implemented as the first lazy-loaded feature!

The effort involved is trivial, and you'll be glad you did it much sooner than you'd expect!

export const routes: Routes = [
  {
    path: '',
    pathMatch: 'full',
    redirectTo: 'dashboard'
  },
  // application with a single feature 
  // implemented as a first lazy loaded feature
  {
    path: 'dashboard',
    loadChildren: () => import('./features/dashboard/dahsboard.routes.ts')
      .then(m => m.routes)
  }
]

Having multiple ways to achieve the same thing

Tied to the previous point, we should constantly strive to keep the number of conventions in our workspace to a minimum.

Take routing as a case in point. These days there are at least four ways to configure it:

  • an eager route pointing straight to a component via component

  • a lazy route loading a component using loadComponent

  • a lazy route to a module using loadChildren

  • a lazy route to routes-based features (a feature-x.routes.ts file) via loadChildren

When faced with these options, it's best to choose one pattern and stay consistent.

My recommendation is to always define lazy features through their own route configs with loadChildren — it's the most current and flexible approach. And if a lazy feature ever needs its own sub-navigation, we can lazy load additional components using loadComponent.

That applies even if the lazy feature starts out with just a single component, because requirements are almost certainly going to grow or shift down the line.

This strategy lets the codebase scale gracefully to whatever level of complexity we need, while preserving a single, uniform way of doing routing project-wide — which frees up mental energy because everything is structured and done identically!

// app.routes.ts
export const routes: Routes = [
  {
    path: 'dashboard',
    loadChildren: () => import('./features/dashboard/dahsboard.routes.ts')
      .then(m => m.routes)
  }
]

// dahsboard.routes.ts (routes based lazy feature)
export const routes: Routes = [
  {
    path: '',
    loadComponent: () => import('./dahsboard.component.ts')
      .then(m => m.DashboardComponent)
  },
  
  // which is easy to extend with in the future, eg
  {
    path: 'editor',
    loadComponent: () => import('./dahsboard-editor.component.ts')
      .then(m => m.DashboardEditorComponent)
  },
  
  // or a larger sub-feature
  {
    path: 'forecast', // forecast sub lazy feature added later
    loadChildren: () => import('./forecast/forecast.routes.ts')
      .then(m => m.routes)
  }
]

Prioritizing DRY Over ISOLATION

ISOLATION is about reducing coupling between different parts of a system, and this goal frequently clashes with another popular idea in software engineering — DRY, short for “Don’t repeat yourself”.

DRY is a principle that targets code duplication, aiming to replace repeated code with abstractions. The core thought is that any piece of knowledge or logic should exist in exactly one location within the system.

Consider what happens when the same logic is scattered across multiple places: a new requirement that alters how that logic should behave means updating every single copy consistently. That process is error‑prone and frequently leads to inconsistencies.

In frontend applications, however, prioritizing isolation over ruthlessly eliminating all repetition is worth 3‑10x more. The added abstractions that come with chasing DRY often increase coupling and hurt maintainability!

What we’re advocating is that some duplication in a frontend codebase (for instance, across separate lazy features) is actually beneficial. It lets those features evolve independently as requirements shiftand they inevitably do!

Frontend requirements are frequently quite ad‑hoc, like a bespoke rule applied to a tiny subset of users as part of a specialized flow.

Since frontend work is so often dominated by very specific requirements, the flexibility that isolation brings is far more valuable than abstracting away every last repeated line!

Validating Architecture by Hand Instead of Using Tools

The Angular CLI ecosystem doesn’t ship with particularly powerful tools for analyzing workspace architecture right out of the box.

That likely explains why this subject remains largely untouched and rarely discussed in the global Angular developer community.

NX workspaces, however, offer a solid option for examining architecture and dependency graphs via the nx graph command 👏

Take, for example, verifying that lazy loaded features are isolated from one another. Doing this manually is incredibly tedious and prone to mistakes...

The process looks like this…

  1. open the editor’s search tool
  2. focus on a specific feature folder, like feature-a
  3. then search for ../feature-b (and every other feature as well!)
  4. check whether feature A has a relative import to one of its sibling features — if it does, that’s a violation of the isolation rule for sibling lazy features

While it technically works, this approach is exasperating at best. No one in their right mind would voluntarily do this on a regular basis!

So, what are the better options?

Madge

Madge is a developer tool for generating a visual graph of your module dependencies, finding circular dependencies, and giving you other useful info

— madge npm docs

My go‑to tool for assessing the health of an Angular codebase is called madge. It can produce a dependency graph visualization for the entire project with just a single command!

npx madge src/main.ts --ts-config tsconfig.json --image ./deps.webp

and that’s all there is to it!*

(* just remember to tweak the paths to match your workspace’s actual layout)

The tool crawls through every .ts file (along with everything they import, and so on) and generates an image that’s straightforward to interpret. You can quickly tell:

✅ whether it looks tidy and ordered, flowing left to right
🔥 or whether it resembles a mess of tangled lines drawn by some very confused spiders

These graphs come in handy for several purposes:

  • evaluating the overall health of the codebase
  • spotting specific areas that need improvement
  • sharing a visual with non‑technical team members

That last one is particularly valuable when you need to make a compelling case for refactoring or tackling technical debt, which ultimately speeds up delivery for the whole organization but is often difficult to explain clearly.

Madge generated dependency graph from3 different projects with various levels of organization fro clean to insane

Eslint plugin boundaries

Integrating eslint-plugin-boundaries is one of the simplest and most reliable methods for keeping your Angular application architecture in good shape over its entire lifecycle!

It lets you define types and rules that capture your intended architecture in only a handful of configuration lines.

Architecture diagram example

The plugin operates purely on your folder structure. That means zero extra overhead and no modifications to how the application code itself is written!

The architecture we discussed earlier can be laid out using the following architectural type configuration…

{
  "overrides": [
    {
      "files": ["*.ts"],
      "plugins": ["boundaries"],
      "settings": {
        "boundaries/elements": [
          {
            "type": "core",
            "pattern": "core"
          },
          {
            "type": "feature",
            "pattern": "feature/*",
            "capture": ["feature"]
          }
        ]
      }
    }
  ]
}

Once the types are established, you can define the rules that dictate which connections are permitted within this dependency structure…

{
  "overrides": [
    {
      "files": ["*.ts"],
      // rest omitted for brevity
      "rules": {
         "boundaries/element-types": [
            "error",
            {
              "default": "disallow",
              "rules": [
                {
                  "from": "core",
                  "allow": ["core"]
                },
                {
                  "from": "feature",
                  "allow": ["core"]
                },
              ]
            }
          ]
      }
    }
  ]
}

These rules achieve two key things: they block any imports between features (protecting isolation) and prevent imports from a feature into core (safeguarding the eager/lazy boundary).

The real beauty here is that it gives you a completely automated, routine validation that runs on every pull request or build, giving you a rock‑solid guarantee that your architecture stays pristine for the project’s entire life!

Want to cut to the chase and get a proven, scalable setup for automated Angular architecture validation?

Look into my eBook — it provides an extensive standard set of architecture types, the rules governing their relationships, and thorough guidance on what to place in each type.

Furthermore, it includes a ready‑to‑use example repository. You can use it as a foundation for your next Angular project, or as a concrete reference for introducing this architecture into your existing applications!

Learn more about Angular Enterprise Architecture eBook now!

Learn more about Angular Enterprise Architecture eBook now!

Forgetting About the Dependency Graph

As several earlier points have demonstrated, a clean, well‑structured architecture is fundamentally tied to the dependency graph that exists beneath our codebase.

Even though it’s not obvious while you edit a single file, you should always be conscious of what’s happening in the background and how your modifications affect the larger system!

In general, these three aspects should always be top of mind and protected:

  • ensuring the dependency graph maintains its one‑way orientation — this aligns directly with keeping a clean eager/lazy boundary. It can also be refined to support a one‑directional relationship where a lazy child feature can import from its lazy parent, but never the reverse.

  • maintaining isolation between separate branches of the graph — this is the direct equivalent of keeping sibling lazy features (at the same navigation level) entirely independent of one another.

  • on a smaller scale, preventing any cycles from forming in the graph — cycles often break the previous two rules and make it much harder to extract and reuse feature‑specific logic later.

Visual example of how one way dependency graph is exclusive with circular dependencies

Lacking a clear strategy for sharing logic and components

The next frequent problem is easy to demonstrate with this example…

Picture this: you’ve already got two isolated lazy features up and running, and now a third one needs to be built.

During development, you notice that a component in feature A could tackle a similar problem in your new feature C.

In this situation, the most common — and regrettable — move is to directly import that standalone component into feature C and move on, mission “accomplished”.

Yes, the app works and lazy bundling isn’t broken. But you’ve just quietly introduced coupling between the features, throwing away the isolation you had painstakingly created and losing all its advantages!

One small component may not seem like a major issue, but this pattern typically snowballs over time. The dependency graph gets tangled, and you find yourself unable to modify feature A without accidentally breaking feature C — slowing you down and opening the door to regressions!

What would be the correct alternative?!

Assuming you have a well‑defined layer like ui intended for reusable components, the correct procedure is to take that component out of feature A and move it into ui.

Doing this right also involves:

  • reviewing the component to strip out anything feature‑specific, making it genuinely generic (which is typically feasible and the right call)
  • relocating the component under the ui/ directory
  • importing it into both feature A and feature C and wiring it up there

From that point forward, both features can use the newly extracted generic component without any concerns.

With that, we’ve fully maintained the one‑way dependency direction, preserved isolation, and kept the architecture clean!

Not Learning Angular’s Two Core Systems and Their Rules

Essentially everything in an Angular application is determined by two foundational systems:

  • template context — which items are accessible within the template of a given component?

  • injector hierarchy — which specific instance of a service does component A (or its service) receive?

These two systems are for Angular what lazy loading and JavaScript bundles are for module structure — they form the fundamental reality every line of code exists in, and thus they influence everything we build, with particular impact on architecture.

Architecturally, the crucial question becomes: where should we create our components and services so they can be consumed in templates or injected in the appropriate features while upholding a clean structure?

A perfect illustration is scoping a service to a single lazy feature. You can do this by removing providedIn: 'root' from the @Injectable() decorator and instead providing the service within that feature’s route configuration.

export const routes: Routes = [
  {
    path: '',
    providers: [ProductService], // scoping service to a lazy feature
    children: [
      {
        path: '',
        loadComponent: () =>
          import('./product-list/product-list.component').then(
            (m) => m.ProductListComponent)
      },
    ],
  },
];

By taking that route, you eliminate the possibility of feature B accidentally injecting a service meant exclusively for feature A!

And when some legitimate need for the service arises outside that feature, you’re compelled to handle it in a more sanitized way. For example, you might elevate the service to meet the needs of the parent lazy feature, or push it all the way up to the core!

Ignoring Standalone Components

Angular introduced standalone components¹ in version 14. That means it’s been more than two years since NgModules stopped being a requirement!

Standalone components offer the greatest flexibility, especially for building generic, reusable presentational components. These are purely defined by their inputs and outputs and remain detached from any particular logic or data source.

Using standalone components as opposed to NgModules results in a more granular dependency graph. This finer resolution makes it significantly easier to see how individual pieces of the app interact and uncover hidden architectural problems!

This approach also gives lazy features the power to pick only the specific UI components they truly need, rather than importing a bundled SharedModule containing everything. That was a common pain point when NgModules were the norm.

Why Angular architecture matters

By now, you should have a solid grasp of the ten most frequent architectural pitfalls in Angular projects. Even if only a handful of the recommendations resonate with your current situation, applying them to ongoing work — and definitely when scaffolding fresh applications — can make a real difference.

If anything is unclear or you'd like to dig deeper, feel free to reach out through the comment section below or send a direct message on X(Twitter) to @tomastrajan...

And always remember, the future looks promising

Obviously the bright Future (📸 by [Tomas Trajan](https://unsplash.com/@tomastrajan) )

Needless to say, this is the bright Future (📸 by Tomas Trajan in Madeira )

Appreciate the look of the code previews in this post? Check out our newest theme plugin

Skol - the ultimate IDE theme

Skol - the ultimate IDE theme

Bring the northern lights straight into your editor. This understated yet powerful dark theme is easy on the eyes and looks great.

Want to build smarter UIs by combining Angular with AI?

Angular + AI Video Course

Angular + AI Video Course

A practical, hands-on course that walks through integrating AI capabilities into Angular applications with Hash Brown, resulting in intelligent and reactive user interfaces.

The curriculum covers streaming chat features, tool calling, generative UI components, structured outputs, and more — incrementally.

Enjoying this article and want to know more about keeping your Angular application maintainable long-term?

Angular Enterprise Architecture eBook

Angular Enterprise Architecture eBook

Discover how to design a new Angular project — or restructure an existing one — for enterprise standards, complete with tooling-based automated architecture validation.

This approach helps guarantee that your codebase remains maintainable, extendable, and supports fast delivery throughout the entire project lifecycle!

Enjoy the content and want to get to grips with Angular's latest Signal Forms?

Angular Signal Forms: Hands-On Masterclass

Angular Signal Forms: Hands-On Masterclass

Get to know Angular's new Signal-Forms through 12 progressive chapters blending theoretical explanations with practical, hands-on labs.

You'll cover the essentials of forms, validators, custom controls, subforms, migration paths, and much more!

Win win deal illustration

Stay in the loop
with new posts

Join the Angular Experts Content Updates & News list and you'll get an email whenever a new blog post on Angular, Ngrx, RxJs, or other interesting frontend topics goes live.

Your email stays private and you can unsubscribe whenever you like!

Emails may include additional promotional content; for more details, see our Privacy policy.

Responses & comments

Ask away and feel free to add your own insights and experiences on the subject

Tomas Trajan - GDE for Angular & Web Technologies

Tomas Trajan

Google Developer Expert (GDE)
for Angular & Web Technologies

I help developer teams ship successful Angular applications via training and consulting, with a particular focus on Architecture and State Management with NgRx!

A Google Developer Expert for Angular & Web Technologies, working as a consultant and Angular trainer. Currently helping enterprise teams worldwide implement core functionality and architecture, adopt best practices, share knowledge, and streamline workflows.

Tomas consistently aims to deliver maximum value to clients and the broader developer community. His work is evidenced by a long list of widely-read industry articles, talks at international conferences and meetups, and contributions to open-source projects.

52

Blog posts

4.7M

Blog views

3.5K

Github stars

612

Trained developers

39

Given talks

8

Capacity to eat another cake

You might also like

See below for related posts from Angular Experts and keep exploring topics like Angular or Modern Angular !