Blog
Juri Strumpflohner
March 18, 2025

Angular Architecture Guide To Building Maintainable Applications at Scale

Angular Architecture Guide To Building Maintainable Applications at Scale
Angular Week Series

This article is part of the Angular Week series:

Software Architecture consists of a variety of aspects that need to be considered. One key aspect though is to maximize the ability to remain flexible and adaptable to new customer requirements. Maybe you've already come across the "Project Paradox":

Project Paradox

A good software architecture helps mitigate the Project Paradox by enabling reversible decisions, progressive evolution, and delaying commitments until more knowledge is available. This can be achieved by aiming for a modular design that facilitates encapsulation, allowing incremental changes, and decoupling dependencies with clearly defined boundaries.

In this article we focus mostly on:

  • how to implement a scalable architecture, not only at runtime, but at development time
  • how to structure your codebase and establish boundaries
  • how to encode and automate best practices to ensure their longevity

Pizza-boxes, Onions and Hexagons - How to organize code

Probably the simplest and most widespread (and probably most straightforward) approach for separating different aspects of an application is the layered architecture (in Italy we call them Pizza-box architecture).

layered-architecture.avif

If you ever looked into software architecture and structuring of projects, this is probably what you've come across. There are variations of this such as the hexagonal and onion architecture, which differ mostly in how dependencies are wired up. The common denominator of these architectures (often also denoted as horizontal approaches) is that they are mostly focused on dividing the system based on technical responsibilities.

On the other hand, vertical architecture approaches organize the application into functional segments or domains focusing on the business capabilities. This is a common approach in microservices architectures.

Possible domain architecture for our example application

Breaking up the Monolith - How to identify where boundaries are

Moving from a layered architecture to domain oriented

Instead of organizing code by technical types (components, services, directives) we want to structure our codebase around business domains. Good candidates for domains are areas that:

  • have distinct business capabilities (e.g. in the example of an online shop: orders, products, payments)
  • reflect team structure within the organization; domain boundaries often mirror organizational structure (Conway's Law)
  • can evolve independently from each other, at different speeds
  • have clear responsibilities and boundaries

To use the example of an e-commerce application, we might have:

  • Products: Product catalog, inventory, categorization
  • Orders: Order processing, history, fulfillment
  • Checkout: Payment processing, cart management
  • User Management: Authentication, profiles, preferences
  • Shipping & Logistics: Delivery options, tracking, address management

All of these need to work together for fulfilling the business requirements, but they can evolve independently and responsibilities can clearly be associated. In general, start broad and then refine over time as you gain more insights.

Having such boundaries clearly separated not only helps with the longer-term maintainability of the application, but also helps assign teams and minimizes cross-team dependencies.

Start Small, Grow as you Need It

A common mistake is to think too much about the ideal end goal and prepare things "just in case". Yep, over-engineering. Exactly, you might not need a monorepo (at least not yet). However, you want to make sure to not add roadblocks in your way.

A lot of our Angular users don't necessarily start to use Nx because they need a monorepo, but because they want to be able to modularize their monolithic codebase. Hetzner Cloud - one of our customers - is a good example for that. Their main goal for initially adopting Nx was to break apart their monolith.

If you want to start building a single Angular application with Nx, you can use the --preset=angular-standalone flag:

npx create-nx-workspace myshop --preset=angular-standalone

This creates a new Angular workspace (not a monorepo) with a single application located in the src folder.

1└─ myshop 2 ├─ e2e 3 │ ├─ ... 4 │ ├─ playwright.config.ts 5 │ └─ tsconfig.json 6 ├─ public/ 7 ├─ src 8 │ ├─ app 9 │ │ ├─ ... 10 │ │ ├─ app.component.ts 11 │ │ ├─ app.config.ts 12 │ │ └─ app.routes.ts 13 │ ├─ index.html 14 │ ├─ ... 15 │ └─ main.ts 16 ├─ eslint.config.mjs 17 ├─ jest.config.ts 18 ├─ jest.preset.js 19 ├─ nx.json 20 ├─ project.json 21 ├─ tsconfig.app.json 22 ├─ tsconfig.editor.json 23 ├─ tsconfig.json 24 └─ tsconfig.spec.json 25

It uses Nx for running and building your project. Nx relies on the Angular Devkit builders but might also add in its own to fill in gaps (e.g. adding Jest/Vitest support). Have a look at the project.json:

project.json
1{ 2 "name": "myshop", 3 "sourceRoot": "./src", 4 "targets": { 5 "build": { 6 "executor": "@angular-devkit/build-angular:browser", 7 "outputs": ["{options.outputPath}"], 8 "options": { 9 "outputPath": "dist/myshop", 10 "index": "./src/index.html", 11 "main": "./src/main.ts", 12 ... 13 }, 14 "configurations": {...}, 15 "defaultConfiguration": "production" 16 }, 17 "serve": { 18 "executor": "@angular-devkit/build-angular:dev-server", 19 ... 20 }, 21 ... 22 "lint": { 23 "executor": "@nx/eslint:lint", 24 "options": { 25 "lintFilePatterns": ["./src"] 26 } 27 }, 28 "test": { 29 "executor": "@nx/jest:jest", 30 "outputs": ["{workspaceRoot}/coverage/{projectName}"], 31 "options": { 32 "jestConfig": "jest.config.ts" 33 } 34 }, 35 "serve-static": { 36 "executor": "@nx/web:file-server", 37 "options": { 38 "buildTarget": "myshop:build", 39 "port": 4200, 40 "spa": true 41 } 42 } 43 } 44} 45

If you have an existing Angular CLI project, you can also add Nx support to it by running:

npx nx init

If you already know you want to go straight to an Nx monorepo, you can add the --integrated flag to the nx init command.

Modularize your Code into Projects Following Your Domain Areas

When starting with Angular, you might structure your application like this:

1src/ 2├── app/ 3│ ├── auth/ # Authentication feature 4│ ├── products/ # Product management feature 5│ ├── cart/ # Shopping cart feature 6│ └── checkout/ # Checkout feature 7├── assets/ 8└── styles/ 9

This feature-based organization is already an improvement over the traditional "type-based" structure (where code is organized by technical type like components/, services/, etc.). However, it still has limitations:

  • Boundaries are purely folder-based with no real enforcement
  • Easy to create unwanted dependencies between features
  • Hard to maintain as the application grows
  • No clear rules about what can depend on what

Instead of relying on folder-based separation, we can create dedicated projects (also called libraries) for different parts of our application. Looking at our workspace structure, we have organized our code into domain-specific projects:

1myshop/ 2├── src/ # Main application 3└── packages/ # Library projects 4 ├── products/ # Product domain 5 ├── orders/ # Order management 6 ├── checkout/ # Checkout process 7 ├── user-management/ 8 ├── shipping-logistics/ 9 └── ... 10

These projects aren't necessarily meant to be published as npm packages - their main purpose is to create clear boundaries in your codebase. The application still builds everything together, but the project structure helps maintain clear separation of concerns.

As domains grow more complex, you might want to split them further into more specialized libraries. For example, our products domain is organized as:

1packages/products/ 2├── data-access/ # API and state management 3├── feat-product-list/ # Product listing feature 4├── feat-product-detail/ # Product detail feature 5├── feat-product-reviews/ # Product reviews feature 6├── ui-product-card/ # Reusable product card component 7└── ui-product-carousel/ # Product carousel component 8

This structure follows a pattern where each domain can have:

  • Feature libraries (feat-*): Implement specific business features or pages
  • UI libraries (ui-*): Contain presentational components
  • Data-access libraries: Handle API communication and state management

Since this is a standalone application (not a monorepo), we use TypeScript path mappings to link these projects together. In our tsconfig.base.json, you can see how each project is mapped:

tsconfig.base.json
1{ 2 "compilerOptions": { 3 "paths": { 4 "@myshop/products-data-access": [ 5 "packages/products/data-access/src/index.ts" 6 ], 7 "@myshop/products-feat-product-list": [ 8 "packages/products/feat-product-list/src/index.ts" 9 ], 10 "@myshop/products-ui-product-card": [ 11 "packages/products/ui-product-card/src/index.ts" 12 ], 13 ... 14 } 15 } 16} 17

These mappings allow you to have clear imports in your code:

1// Clear imports showing the domain and type of code you're using 2import { ProductListComponent } from '@myshop/products-feat-product-list'; 3import { ProductCardComponent } from '@myshop/products-ui-product-card'; 4import { ProductService } from '@myshop/products-data-access'; 5

This makes it immediately obvious:

  1. Which domain the code belongs to (products)
  2. What type of code it is (feat-*, ui-*, data-access)
  3. What specific feature or component you're importing

The Application is Your Linking and Deployment Container

Distribution of apps and libs in an Nx workspace

In a well-modularized architecture, your application shell should be surprisingly thin. Think of your main application as primarily a composition layer: it imports and coordinates the various domain libraries but contains minimal logic itself.

The ideal application structure has:

  • Thin application shell - Contains mainly routing configuration, bootstrap logic, and layout composition
  • Domain libraries - All business logic, UI components, and data access code

At the Angular router level you then import the various feature libraries:

1... 2export const appRoutes: Route[] = [ 3 { 4 path: 'products', 5 loadComponent: () => 6 import('@myshop/products-feat-product-list').then( 7 (m) => m.ProductsFeatProductListComponent 8 ), 9 }, 10 { 11 path: 'product/:id', 12 loadComponent: () => 13 import('@myshop/products-feat-product-detail').then( 14 (m) => m.ProductsFeatProductDetailComponent 15 ), 16 }, 17 { 18 path: 'reviews', 19 loadComponent: () => 20 import('@myshop/products-feat-product-reviews').then( 21 (m) => m.ProductsFeatProductReviewsComponent 22 ), 23 }, 24 { 25 path: 'orders', 26 loadComponent: () => 27 import('@myshop/orders-feat-order-history').then( 28 (m) => m.OrdersFeatOrderHistoryComponent 29 ), 30 }, 31 { 32 path: 'create-order', 33 loadComponent: () => 34 import('@myshop/orders-feat-create-order').then( 35 (m) => m.OrdersFeatCreateOrderComponent 36 ), 37 }, 38 { 39 path: 'checkout', 40 loadComponent: () => 41 import('@myshop/checkout-feat-checkout-flow').then( 42 (m) => m.CheckoutFeatCheckoutFlowComponent 43 ), 44 }, 45 ... 46 { 47 path: '', 48 redirectTo: 'products', 49 pathMatch: 'full', 50 }, 51]; 52

This routing configuration is just an example to convey the idea. You can go even further by having the top level domain-entry routing at the application and then domain specific routing is added by the various domain libraries themselves.

When building, Nx compiles all the imported libraries together with your application code, creating a single deployable bundle. The libraries themselves don't produce deployable artifacts, they are implementation details that are consumed by the application.

This pattern makes it much easier to move features between applications later if needed, as your business logic isn't tied to any specific application shell. It also provides a clear mental model: applications are for deployment, libraries are for code organization and reuse.

When to Create a New Library

There is really no correct or wrong answer here. You should not just go and create a library for each component. That's probably too much. It really depends on how closely related various components or use cases are.

Let's have a look at our current product domain example:

1packages/products/ 2├── data-access/ # API and state management 3├── feat-product-list/ # Product listing feature 4├── feat-product-detail/ # Product detail feature 5├── feat-product-reviews/ # Product reviews feature 6├── ui-product-card/ # Reusable product card component 7└── ui-product-carousel/ # Product carousel component 8

We could easily just have a single feat-product-list which contains both, the list as well as detail view navigation because they might be closely connected. Similarly we could group ui-product-card and ui-product-carousel into a single ui-product library.

A good rule of thumb is to understand and see how often various parts change over time. As your application grows, watch for these signs that your library boundaries might need adjustment:

  • Frequent Cross-Library Changes - You consistently need to modify multiple libraries for a single feature change
  • Circular Dependencies - Libraries depend on each other in ways that create circular references
  • Unclear Ownership - Multiple teams frequently need to coordinate to modify the same library
  • Complex Dependencies - Simple features require importing from many different libraries or domains
  • Excessive Shared Code - You find yourself duplicating types and utilities across domains

Remember that library boundaries aren't set in stone - they should evolve with your application. Start with broader boundaries and refine them as you gain insights into how your code changes together.

Guard Your Boundaries - Automatically Enforcing Clear Dependencies

Once you've established your domain boundaries and architectural layers, you need to ensure they remain intact as your codebase grows. Nx provides powerful tools to enforce these boundaries through module boundary rules that can be configured in your ESLint configuration.

In our example, we use a dual-tagging approach:

  1. Scope tags (scope:<name>): These reflect our domain boundaries, representing different business capabilities like products, orders, checkout etc. They encode our vertical slicing approach.
  2. Type tags (type:<name>): These represent our horizontal architectural layers such as feature, ui, data-access, and util.

Here's how the rules are configured:

1// Type-based rules 2{ 3 sourceTag: 'type:feature', 4 onlyDependOnLibsWithTags: ['type:feature', 'type:ui', 'type:data-access'] 5}, 6{ 7 sourceTag: 'type:ui', 8 onlyDependOnLibsWithTags: ['type:ui', 'type:util', 'type:data-access'] 9}, 10 11// Domain-based rules 12{ 13 sourceTag: 'scope:orders', 14 onlyDependOnLibsWithTags: ['scope:orders', 'scope:products', 'scope:shared'] 15}, 16{ 17 sourceTag: 'scope:products', 18 onlyDependOnLibsWithTags: ['scope:products', 'scope:shared'] 19} 20

The rules enforce a clear dependency structure:

  • Type rules ensure architectural layering. For instance, UI components can only depend on other UI components, utilities, and data-access libraries. This prevents circular dependencies and maintains a clean architecture.
  • Domain rules control which domains can talk to each other. For example, the orders domain can depend on products (since orders contain products), but products cannot depend on orders.
  • Every domain can depend on shared code, but shared code can only depend on other shared code, preventing it from becoming a source of circular dependencies.

These rules are enforced at build time through ESLint. If a developer tries to import from a forbidden domain or layer, they'll receive an immediate error, helping maintain the architectural integrity of your application. This allows you to get feedback as early as possible when you run your PR checks on CI.

Note that this tagging structure is just a suggestion - you can adapt it to your specific needs. The key is to have clear, enforceable boundaries that reflect both your technical architecture and your business domains.

Read more about Nx boundary rules in our documentation.

Automate Your Standards

As your workspace grows, it becomes increasingly important to automate and enforce your team's standards and best practices.

The key to successful automation is finding the right balance. Start with automating the most common patterns that need standardization, and gradually add more automation as patterns emerge. Focus on the standards that provide the most value to your team. These automation capabilities are really here to ensure that your team's standards are not just documented but actively enforced through tooling, making it easier for developers to do the right thing by default.

Nx provides powerful mechanisms to achieve this.

Custom Generators for Consistent Code Generation

Nx is extensible. As such it allows you to create custom code generators that you can use to encode your organization's standards and best practices.

The generator itself is just a function that manipulates files:

1import { Tree, formatFiles } from '@nx/devkit'; 2 3export default async function (tree: Tree, schema: any) { 4 // Add your generator logic here 5 // For example, create files, modify configurations, etc. 6 7 await formatFiles(tree); 8} 9

Your team can then run these generators through the Nx CLI (via the nx generate ... command) or Nx Console:

For more detailed information about creating custom generators, including how to add options, create files, and modify existing ones, check out the Local Generators documentation.

Leverage Nx Console AI Integration

If you use Nx Console, Nx's editor extension for VSCode and IntelliJ, then you should already have the latest AI capabilities enabled.

Nx Console just got some enhancements with the goal of providing contextual information to editor integrated LLMs such as Copilot and Cursor. By providing Nx workspace metadata to these models they are able to provide much more valuable, context specific information and perform actions via the Nx CLI.

You can find more detailed information in our documentation about how to enable and use the capabilities.

Single-app vs Multiple App Deployment

Until now we didn't really talk about a monorepo at all. We have a single Angular application and modularized its features into dedicated projects. The projects themselves are really just to encapsulate their logic and structure our application. While we could make them buildable by themselves (mostly for leveraging speed gains from incremental building) most of them do not have build targets. You can test and lint them independently but with a standalone Angular application, all your features are bundled and deployed together as a single unit.

While this approach is simple and works well for smaller applications, there are several scenarios where you might want to split your application:

  • Different scaling requirements: Your customer-facing store might need high availability and scalability to handle thousands of concurrent users, while your admin interface serves a much smaller number of internal users
  • Resource optimization: Not all users need all features. For example, administrative features like inventory management are only needed by staff members
  • Independent deployment cycles: Different parts of your application might need to evolve at different speeds. Your admin interface might need frequent updates for internal tools, while your customer-facing store remains more stable
  • Security considerations: Keeping administrative features in a separate application can reduce the attack surface of your customer-facing application

As your application grows, you might want to split it into multiple applications - perhaps separating your customer-facing storefront from your administrative interface. The first step is to convert your standalone application into a monorepo structure. Nx comes with a convert-to-monorepo command to do exactly that:

nx g convert-to-monorepo

This command moves your existing application into an apps directory and adjusts any configuration that needs to be adjusted for supporting multiple side-by-side applications in a monorepo setup.

Note: If you're looking for a NPM/Yarn/PNPM workspaces based monorepo setup, then make sure to read our article about the New Nx Experience for TypeScript Monorepos.

Creating Multiple Applications

Once you have a monorepo structure, you can create additional applications that share code with your original app. For example, you might want to create an admin application:

nx g @nx/angular:app admin

Now you can move administrative features (like inventory management) to the new admin app by importing libraries relevant to the new app (such as for example the inventory management libraries).

Here's what the new structure could look like:

1myshop/ 2├── apps/ 3│ ├── shop/ # Customer-facing storefront (high availability needed) 4│ └── admin/ # Administrative interface (internal users only) 5└── packages/ 6 ├── products/ # Shared product domain 7 ├── orders/ # Shared order management 8 ├── checkout/ # Shop-specific checkout process 9 └── shared/ # Common utilities and components 10

You can see how the already modular structure allows you to adjust your application structure and re-link some of the packages into the new application. Something that would have otherwise been a major undertaking.

Similarly to how we now have two applications that can be deployed and scaled independently, we could go even further and convert it into a microfrontend approach. But more on that in another article.

Scaling Development

Obviously as your codebase keeps growing you need to have the tooling support that helps keep it sustainable. In particular CI might become a concern as the number of projects grows. For that purpose Nx has several features to keep your CI fast and efficient:

  • Remote Caching (Nx Replay) ensures your code is never rebuilt or retested unnecessarily.
  • Distributed Task Execution (Nx Agents) intelligently allocates tasks across multiple machines.
  • Atomizer helps manage growing test suites by automatically splitting them into more fine-grained runs and by leveraging Nx Agents to parallelize them across machines.
  • Flaky Task Detection identifies flaky tasks (often automated unit or e2e tests) and re-runs them automatically for you.

One of the key advantages of using Nx is that it's not limited to just Angular either. As your application grows, you might need to:

  • Add a documentation site using static site generators like Analog
  • Create landing pages with Next.js or Astro
  • Build backend services with NestJS or Express
  • Add specialized tools for specific business needs

Nx supports all these scenarios while maintaining the ability to share code between different technologies. For example, you could:

1myshop/ 2├── apps/ 3│ ├── shop/ # Main Angular application 4│ ├── admin/ # Angular admin interface 5│ ├── docs/ # Analog documentation site 6│ ├── landing/ # Next.js marketing site 7│ └── api/ # NestJS backend 8└── packages/ 9 ├── products/ # Shared product domain (used by both front and backend) 10 ├── orders/ # Shared order management 11 └── shared/ # Common utilities and types 12

The modular structure we established earlier makes it easy to share types and interfaces between frontend and backend and reuse business logic across different applications.

Wrapping up

Building maintainable Angular applications at scale requires thoughtful architecture decisions and proper tooling support. We've covered several key aspects:

  1. Domain-Driven Structure: Moving from traditional layered architectures to organizing code around business domains creates clearer boundaries and better maintainability.

  2. Incremental Adoption: Starting small with a standalone application and growing into a more complex structure as needed, rather than over-engineering from the start.

  3. Clear Boundaries: Using projects/libraries to create explicit boundaries between different parts of your application, with automated enforcement through module boundary rules.

  4. Automation & Standards: Leveraging custom generators and AI-enhanced tooling to maintain consistency and best practices across your codebase.

  5. Scalability Options: Understanding when and how to evolve from a single application to multiple applications or even microfrontends, while maintaining code sharing and reusability.

Remember that architecture is not a one-time decision but an evolving process - start with clear boundaries and good practices, then adapt as your application and team's needs grow. The key to success lies in having the proper tooling and automation in place to support you as your application grows.


Learn more: