Discover how to structure your Angular library using sub-entries for the smallest bundle size and a codebase that stays maintainable over time!

emoji_objects emoji_objects emoji_objects
Tomas Trajan

Tomas Trajan

@tomastrajan

Sep 15, 2020

16 min read

The Best Way To Architect Your Angular Libraries
share

Getting it right from the very beginning is often worth the effort 😉 (📸 by Gabriel Sollmann )

Don't miss the Berner JS meetup video featuring live coding of every idea discussed here!

The background

Everything you'll read below stems from hands-on work in a massive corporate setup, where we juggle 100 Angular SPAs alongside more than 30 libraries…

🤫 Curious how we handle such a sprawling ecosystem without losing our minds 😵 Then take a look at Omniboard!😉


To give you the full picture: what sparked this deep dive was building a second "component framework" (our internal take on Angular Material) using this strategy, then measuring it against the older one that skips ng-packagr sub-entries…

Thanks to sub-entries, tree-shaking kicks in—for instance, importing MyOrgDropdownModule pulls in only that specific sub-entry (plus whatever it explicitly references, like MyOrgIconModule) and nothing more!

That's often the gap between 1.5 MB and 50 KB!

On top of that, this approach shines with lazy-loaded modules—not just main / eager versus lazy splits—since sub-entries let Angular CLI go further, carving out a virtual chunk that loads only when a particular set of lazy features actually needs it!


What we'll cover

  • Creating an Angular library with clean architecture from zero (demo code included)

  • Setting up one sub-entry per feature (and streamlining it with ng-samurai schematics)

  • Configuring correct Typescript “paths” aliases

  • How this setup catches bad imports and circular dependencies automatically before we even run a build

  • Inspecting library layout using madge

  • Consuming the library from external apps

  • Keeping internal pieces hidden

  • Moving dependencies into consumer apps (dependencies instead of peer dependencies)

  • Building a demo app for the library within the same workspace

  • Getting all of this to cooperate with jest

That's a hefty agenda, so jump straight to the part you need based on where you're at 😊

Here we go!

Building our library from the ground up

Here we'll spin up a fresh Angular library project, leaning on *Angular CLI Schematics ** (details here*) so you won't have to type much by hand 😉

  1. Set up a new workspace without a default app ng new angular-library-architecture-example --createApplication false --prefix my-org
  2. Move into the project folder cd angular-library-architecture-example
  3. Add a fresh library ng g library some-lib --prefix my-org
  4. Update the name field inside the nested (lib) package.json to @my-org/some-lib, since the prefix flag only handles selectors for components and directives, not the package name
  5. Edit the root tsconfig.json, swapping out the original paths with the block below…
{
  "paths": {
    "@my-org/some-lib/*": [
      "projects/some-lib/*",
      "projects/some-lib"
    ],
    "@my-org/some-lib": [
      "dist/some-lib/*",
      "dist/some-lib"
    ]
  }
}

The first entry (referenced as @my-org/some-lib/*) is applied during development when sub-entries reference each other…

In contrast, the second entry is applied during the build stage to generate library artifacts that applications can consume as needed…

  1. Remove all content from the projects/some-lib/src/lib/ directory and wipe the root public-api.ts file so it becomes blank.

Excellent! Our initial configuration is complete. Let’s begin building features with sub-entries!

Next, we’ll craft our first sub-entry by hand—this helps grasp the concept. Once done, we’ll switch to the handy ng-samurai schematics to automate the process for us…

  1. Inside projects/some-lib/src/lib/, create a new feature-a/ directory.

  2. Add index.ts, ng-package.json, and public-api.ts files into that feature-a/ directory.

  3. In index.ts, re-export everything via export * from './public-api.ts';

  4. The ng-package.json must include the following configuration…

 {
   "$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
   "lib": {
     "entryFile": "public-api.ts",
     "cssUrl": "inline"
   }
 }

Keep in mind that the contents of both index.ts and ng-package.json will remain identical across all generated sub-entries…

All the pieces that belong to a given sub-entry—whether they are modules, components, services, or directives—are meant to be re-exported through the public-api.ts file.

At this stage, no such piece exists yet. We can generate one with the standard Angular CLI schematic ng g s feature-a/a, which produces a feature-a/a.service.ts file inside the feature-a/ directory.

With the service in place, we can now expose it via the public-api.ts file.

export * from './a.service';

As demonstrated, setting up sub-entries is remarkably simple, though it involves a fair amount of boilerplate. To streamline this, we'll rely on Kevin's ng-samurai Angular Schematics pack, which spins up entire sub-entries with just one command!

Expanding with additional sub-entries

Now, let's bring in the npm i -D ng-samurai Angular Schematics tool and employ it to scaffold a fresh sub-entry for feature B, executed via ng g ng-samurai:generate-subentry feature-b --gm false --gc false.

The --gm and --gc options correspond to generating a module and a component, respectively. Depending on the feature's needs, we could generate the standard module and component, but for sub-entries that only provide a service, we might skip them entirely…

This command produces a sub-entry lacking both a module and a service, so we'll craft a new service ourselves with ng g s feature-b/b.

Tip: When constructing Angular libraries, it's best practice to always opt for providedIn: 'root' for new services (a default this is already applied when scaffolding services via Angular CLI schematics). If a library is made up solely of such services, we wouldn't need sub-entries, as these services are inherently tree-shakeable without any extra effort.

Even so, it's highly probable that we'll need to introduce modules (with their components and directives) later as feature requirements grow. Therefore, for any Angular library that's anything but trivial, adopting sub-entries right from the outset is always the wisest move!

Alright, let's add a further sub-entry for feature C using the same method we've just outlined, complete with its own service. This time, though, we'll also produce a module and a component to leverage in our upcoming library demo showcase…

Ensure that every service, module, and component is properly exported from the appropriate public-api.ts` file associated with its specific sub-entry.

Re-exporting at the top level

With all feature sub-entries ready, we're not done yet; we must also list their exports in the main `projects/some/lib/src/public-api.ts file.

To accomplish this, we'll take advantage of the Typescript paths aliases we configured earlier, making the final export appear as export * from ‘@my-org/some-lib/src/lib/feature-a’;. We'll perform this same step for every single sub-entry we've built…


Verification checkpoint

Our foundational library structure is now fully in place, so let's attempt to compile the library with the ng build command.

A quick look at the console results will show us a separate output for each implemented (and exported) sub-entry, something along these lines…

Example of a console output when building Angular library implemented using ng-packgaer sub-entries

Now let’s examine the dist/ directory, where the generated output lives.

Notice that the build produced a distinct file for each sub-entry, plus a separate main file corresponding to the top-level public-api.ts, which re-exports every sub-entry’s bundle…

Files generated by the Angular build process when building library that was implemented using sub-entries

Thanks to this, our applications can reference the public API @my-org/some-lib directly—no more deep imports—and still benefit from complete tree-shaking!

Follow me on Twitter and I’ll keep you posted about fresh Angular articles and frontend tips!😉

SIGNUP can be moved to desired target within blog post

Composing sub-entry logic

Excellent—our base skeleton is ready and the build pipeline works. Now let's add some realism to the picture…

Often we want internal features to depend on each other. This means using one sub-entry’s exported pieces inside another sub-entry of the same library.

Consider a logging/ helper: it's part of the public API for consumer apps, but it also plays a role inside interceptors/, data-access/, or tracing/—a perfect illustration of cross-entry reuse.

To try this in our current project, we just inject B service into A service. This is done via the constructor—simply write constructor(private b: BService) { } inside a.service.ts.

import { Injectable } from '@angular/core';

import { BService } from '@my-org/some-lib/src/lib/feature-b';

@Injectable({
  providedIn: 'root'
})
export class AService {
  constructor(private b: BService) { }
}

How the import gets resolved often depends on the editor in use, though we can always handle it manually—and the proper import to add is…

import { BService } from '@my-org/some-lib/src/lib/feature-b';

Our service references another sub-entry, which is why we rely on a TypeScript path alias instead of a relative import.

Running ng build should succeed without any issues!

This is the perfect moment to dive into the architectural perks that come at no extra cost, all because of the sub-entry structure!

Circular dependencies between sub-entries

Earlier, we had service A depending on service B. Now, let’s flip that and have service B depend on service A, creating a circular reference, and then attempt another build with ng build.

🅰️➡️🅱️➡️🅰️

The build output should reveal an error…

Our build failed because we introduced circular dependency between our sub-entries

This error message is quite helpful, as it allows us to quickly identify the source of the issue. However, it doesn't always appear in every scenario…

For instance, if a circular dependency is introduced through feature C

🅰️➡️🅱️➡️©️➡️🅰️ (unfortunately, emojis lack a proper C 😿, quite disappointing)

Depending on the Angular CLI version in use, we might encounter a clear, descriptive error, an Out of Memory crash, or even no feedback at all (e.g., the build silently fails without generating any JavaScript files in the dist/ folder, leaving room for enhancement?).

Whenever such a situation arises, it indicates the introduction of circular dependencies, steering us towards a tangled, difficult-to-maintain codebase where every component relies on every other! This is an ideal moment to avoid it altogether!

To resolve the issue, we can return to 🅰️➡️🅱️➡️©️, and things will operate smoothly! This requires removing the injection of service A from service C and then rebuilding the library with ng build.

It's worth noting that the sub-entries in the build log are arranged according to the dependency graph, starting with the leaf sub-entry (feature C) and progressing up to the library root public-api.ts.

Examining the dependency graph

So far, we've discussed circular dependencies and the dependency graph concept, illustrating it with simple emoji-based chains. However, this method doesn't work well for real-world projects with many components…

Fortunately, an excellent open-source tool named madge can create a dependency graph as a visually appealing image!

Be cautious, as installation might be tricky depending on your operating system, Windows especially 😅…

After globally installing madge with npm i -g madge (along with any required environment dependencies), we can execute it using madge projects/some-lib/src/public-api.ts --ts-config tsconfig.json --image graph.png, producing output similar to the following…

The Best Way To Architect Your Angular Libraries - Angular Experts — figure 6

Should we restore the earlier circular chain — 🅰️➡️🅱️➡️©️➡️🅰️ — and execute madge again, the resulting diagram would shift dramatically.

Entities marked in red are part of at least one circular dependency chain!

The guideline is straightforward: red signals trouble, which points to a circular dependency!


Correct imports are enforced automatically

This configuration comes with another major benefit: it enforces proper import patterns whenever we reference code across different sub-entry points.

Consider a scenario where service C needs to be used inside service B. The recommended approach is to write import { CService } from '@my-org/some-lib/src/lib/feature-c'; — however, what happens if our IDE suggests, or we accidentally type, import { CService } from '../feature-c/c.service';?

Writing something like that indicates we're pulling in a dependency from another sub-entry using a relative path reference

Running ng build on our library would trigger an error like this…

The Best Way To Architect Your Angular Libraries - Angular Experts — figure 8

The presence of the rootDir keyword is the critical detail to notice here.

When this error appears, it signals that a relative import is crossing a sub-entry boundary—and that is exactly what we want to catch!

Sub-entries offer multiple built-in validations that help us stay on the right track!

How to consume library in the Angular applications

With this configuration, we can import everything from the main @my-org/some-lib entry point, no matter which sub-entry actually hosts the service, component, or module.

We can pair this with a custom import-blacklist Tslint rule to block deep imports from our libraries, for instance @my-org/some-lib/(?!testing).*.

This rule forbids all deep imports except for @my-org/some-lib/testing, which gives us a solid foundation for ensuring proper consumption of the library.

Once everything is in place, we can utilize a service like A by writing import { AService } from '@my-org/some-lib';.

How to make stuff private

A key condition for this approach to function is that all items implemented in a sub-entry must be exported via its public-api.ts file.

This poses a challenge when there is a large amount of internal (private) code that we do not wish to expose, since consumers might use it simply because it is accessible.

Fortunately, a solution exists. Earlier, we explored how the Tslint import-blacklist rule can deter consumers from using deep imports.

We can achieve privacy by setting up dedicated "internal" sub-entries (e.g., internal-utils/) that are not exported from the root public-api.ts file.

The sole way to access such a sub-entry is via a deep import like @my-org/some-lib/internal-utils, which is disallowed by the earlier Tslint rule, resulting in a linting error—keeping us protected!

While this method works, it feels like excessive effort for a common need like privacy—perhaps there is still room for enhancement?

How to bring dependency into consumer

At times, our library depends on other third-party libraries such as date-fns. In that case, we have two primary choices for ensuring the consumer app receives the dependency, each with its own pros and cons.

1. Dependency will be brought by the library

The dependency can be listed in the INTERNAL library package.json file (it must also be added to whitelistedNonPeerDependencies in the ng-package.json file). This way, installing our library in an app will also install that third-party dependency automatically.

The advantage here is that it eases setup for consumer apps. Developers avoid having to read installation docs or spot missing peerDependency warnings during npm install

The downside is that the consumer might already use a different version of that same library, which can cause various issues if the public API changes across major releases of the third-party library…

Another significant concern is that, even when things function smoothly, multiple copies of the library could end up in the application bundles, hurting startup performance—many thanks to Alan Agius for his feedback to improve this article!

2. Dependency will be declared as a peerDependency

Alternatively, the third-party library can be listed under peerDependencies in the internal package.json file.

In this case, the consumer app is responsible for installing and providing the dependency, which demands more effort from developers on the consuming side but offers greater safety regarding life-cycle management!


Demo app to showcase or test library in the same workspace

There are scenarios where exposing the library's capabilities within the local demo project proves beneficial. You can set up such an application in the same workspace via Angular Schematics by running ng g application some-lib-demo.

The demo application is required to load the library's modules, components, and services by importing them…

Given the Typescript paths aliases established earlier, two primary strategies exist for referencing the library code from the demo app.

The first is to employ conventional @my-org/some-lib imports, mirroring what external consumers would use, yet this demands a prior ng build of the library so its compiled bundles reside within the dist/ folder.

Alternatively, you can import using @my-org/some-lib/src/lib/<sub-entry-name>, which allows the demo app to execute or build without needing a separate library compilation step.

Additionally, this setup triggers live reloads whenever library sources change, making it a more fitting choice for development workflows where rebuilding beforehand is inconvenient—particularly when crafting component-dense libraries and leveraging the demo as a development aid.

Bonus: Make it work with Jest 🃏

Jest's architecture unfortunately prohibits its seamless inclusion within the Angular CLI build process. This also explains why the CLI currently lacks official Jest support, as maintaining a second pipeline would be required…

Testing our library, built upon the architecture outlined in this article, with Jest is straightforward. Simply add a moduleMapper setting to the jest.config.js, ensuring it mirrors the aliases specified in the root tsconfig.json file!

{
    moduleNameMapper: {
        '@my-org\\/some-lib\\/(.*)': '<rootDir>/$1',
    }
}

The exact value placed after <rootDir> is determined by where your jest.config.js file resides. In the scenario shown above, the jest.config.js file is placed within the projects/some-lib/ directory, so the paths for src/lib/feature-a align correctly with $1 pointing to the actual file location.

Bonus: A closer look at the underlying mechanics

A particular problem we encountered prompted discussions with fellow GDEs and even members of the Angular team, who pointed us to several valuable resources for deepening our understanding of how sub-entries and tree-shaking operate within Angular or TypeScript…

We extend our gratitude to George Kalpakas @gkalpakas, Alan Agius @AlanAgius4, Pete Bacon Darwin @petebd, and Joost Koehoorn for their valuable contributions!

EDIT: Are sub-entries necessary with IVY? Absolutely! (21. 07. 2021)

All the information here is grounded in the most recent Angular 12.1.2 release featuring IVY. Moreover, based on what I have discovered, disabling IVY in the newest Angular release is no longer an option…

CommonJS Dependencies

In the absence of sub-entries, these dependencies will not undergo tree shaking and will be included in your main.js, even if the library component that relies on them is completely unused…

Code splitting

Consider a scenario where our library contains three distinct modules—A, B, and C—each containing a single component of the same name, A, B, and C…

Example of a project with described setup

Let’s now suppose that component C holds a public property with a 100 KB string assigned to it, and that string appears directly in its template. Because of that, we can clearly observe which final bundle the string belongs to depending on the setup we choose…

Scenario 1: Library modules are referenced exclusively from the consumer SPA’s lazy—loaded modules

In that case, the result is almost what we’d hope for. The logical expectation is that the 100 KB string would be placed inside the consuming SPA’s LazyModuleC, since that’s where the module is actually imported. However, what actually happens is that the string ends up in a shared, extracted common chunk—the bundler cannot reliably determine whether other lazy modules might also rely on it…

The component C with its 100 KB string was bundled into a “common chunk” so the navigation to lazy B will still download it even though is only used in C

Scenario 2: Library modules are used in both eager AND lazy modules of consumer Angular SPA

Here, we’ll show why skipping sub-entries undermines code splitting. As a result, library module C and its component C (100KB) get bundled into the main.js, despite LazyModuleC being the only place in the consumer that ever imports it…

The 100 KB were removed from lazy part and are loaded eagerly even though they are only consumed in lazy consumer module which is bad

Sub-entries + IVY Summary

Even with IVY, sub-entries remain a necessity

The following scenarios will fail without sub-entries:

  • tree-shaking of CommonJS dependencies

  • code splitting (eager/lazy boundaries)\

This remains valid at least up to Angular 12.1.2

Great, we have reached the end! 🔥

I trust you found this exploration of optimal Angular library architecture and its associated benefits valuable! Be sure to visit the demo project for a hands-on reference.

Feel free to reach out with any questions via the comments section or on Twitter at @tomastrajan.

And always keep in mind, the future is bright

Obviously the bright future! (📷 by [Andreas Sjövall](https://unsplash.com/@andreassjovall))

Is the look of this code sample to your liking? Check out our freshly released theme plugin

Skol - the definitive IDE color scheme

Skol - the ultimate IDE theme

Aurora borealis vibes, right inside your editor. This is a minimal yet powerful dark theme—easy on the eyes, sharp on the screen.

Create more intelligent interfaces with Angular and AI

Angular + AI Video Course

Angular + AI Video Course

A practical, hands-on training where you’ll build intelligent, reactive interfaces in Angular by integrating Hash Brown for AI-powered features.

Progress through streaming chat, tool invocation, generative UI, structured outputs, and beyond—each concept covered in a step-by-step flow.

Create more responsive UIs by combining Angular with AI capabilities

Interactive Video Course on Angular and AI

Angular + AI Video Course

This practical course demonstrates how to bring AI into Angular projects with Hash Brown, crafting responsive and intelligent user interfaces.

Progress through streaming chat, tool invocation, generative UI, structured outputs, and additional topics—one stage at a time.

Find this content useful, and want to get charge of Angular's new Signal Forms?

Angular Signal Forms: An Interactive Workshop

Angular Signal Forms: Hands-On Masterclass

Across 12 step-by-step chapters, this course explains Angular’s recently introduced Signal-Forms by pairing concepts with practical exercises.

You’ll get the hang of core form handling, validation logic, bespoke controls, nested forms, and approaches for transitioning existing code—every topic you need.

Win win deal illustration

Stay in the loop
whenever a fresh post lands

Subscribe to the Angular Experts Content Updates & News feed, and we'll let you know the moment a new article goes live—covering Angular, Ngrx, RxJs, and other hot Frontend subjects!

Your email stays private—we never pass it along, and opting out is a breeze whenever you like!

Additional promotional material may appear in these emails; check our Privacy policy for full details.

Your take & conversation

Feel free to drop your questions, share your insights, or weigh in with your perspective on the subject

Tomas Trajan - GDE for Angular & Web Technologies

Tomas Trajan

Google Developer Expert (GDE)
for Angular & Web Technologies

Google Developer Experts logo X logo LinkedIn logo Github logo Github logo Spotify logo Medium logo public

My mission is to help developer teams build successful Angular applications through focused training and consulting, with a strong emphasis on Architecture and State management using NgRx!

As a Google Developer Expert for Angular & Web Technologies, Tomas works as a consultant and Angular trainer, currently supporting enterprise teams across the globe by shaping core functionality and architecture, sharing best practices, and streamlining workflows.

Tomas strives to deliver maximum value for both customers and the broader developer community, supported by a proven record of publishing popular industry articles, speaking at international conferences and meetups, and contributing 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

Explore these additional blog posts from Angular Experts to uncover deeper insights on related subjects like Angular !

Top 10 Angular Architecture Mistakes You Really Want To Avoid

Top 10 Angular Architecture Mistakes You Really Want To Avoid

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

Angular Signal Inputs

Angular Signal Inputs

Revolutionize Your Angular Components with the brand new Reactive Signal Inputs.

emoji_objects emoji_objects emoji_objects
Kevin Kreuzer

Kevin Kreuzer

@nivekcode

Jan 24, 2024

6 min read

Improving DX with new Angular @Input Value Transform

Improving DX with new Angular @Input Value Transform

Embrace the Future: Moving Beyond Getters and Setters! Learn how to leverage the power of custom transformers or the build in booleanAttribute and numberAttribute transformers.

emoji_objects emoji_objects emoji_objects
Kevin Kreuzer

Kevin Kreuzer

@nivekcode

Nov 18, 2023

3 min read

Leverage our deep expertise for your team

For years, we at Angular Experts have collaborated with both enterprises and emerging startups, delivered workshops and tutorials, and developed a wealth of open source material. Our strong command of modern front-end development is something we’re genuinely proud of, and we’d be excited to support your growth.