$ tree libs -L 2▊

Where does this file go?

The folder structure we ended up with after moving an enterprise Angular app to Nx: two tags name every library, feature libraries are sliced by screen, data-access libraries keep their technical folders, and the app holds almost nothing. This is the reference guide that goes with the migration case study: the shape, the reasons, and the questions a developer (or an agent) asks before creating a file.

Andrii Siuta · October 2026 · ~12 min read Angular Nx architecture
42libraries
9 × 6scopes × types
0code folders in the app
1way to import a library
0technical folders on top of a feature library
0ignored cycles

01. The map

The workspace has one application and forty-two libraries in nine scopes. A scope is a business domain, plus two technical ones: shared for what no domain owns, and shell for the frame that loads everything else. Every library lives exactly two folders deep.

the workspace, top two levels (domain names are placeholders)apps/
  portal/               the one app: bootstrap, config, global styles, assets
libs/
  shell/
    feature/            root routes, guards, interceptors, layout
  shared/
    data-access/        HTTP layer, feature flags, runtime config
    ui/                 design-system wrappers, generic components, shared Sass partials
    util/               domain-free models and pure helpers
    testing/            generic generators and fixtures, for specs only
  orders/
    feature/            the routed screens of the domain
    ui/                 what another domain renders
    data-access/        entity models, the domain's API class, its store
    testing/            the domain's generators
  catalog/
    products-feature/   a big domain gets one feature library per area
    pricing-feature/
    data-access/
    …
tools/                  build scripts and the workspace-shape specs

The path, the project name and the import alias all come from the same two words: libs/orders/data-access is the project orders-data-access and is imported as @org/orders/data-access. Nobody has to look a name up.

02. Two tags name every library

Each library carries a scope: tag (which domain) and a type: tag (which layer). The scope says who owns it; the type says what it may contain and what it may import. The dependency rule is a table over the type, checked by the Nx module-boundaries lint rule:

The layer order Six layers from top to bottom: app, shell, feature, ui, data-access, util. Arrows only point down. Testing sits on the side and is reached from spec files only. appbootstrap only · may import anything shellthe only layer that loads features featurenever imports another feature uicomponents another domain renders data-accessmodels, API classes, stores utilpure helpers, no domain imports point down testing from *.spec.ts files only pink border = scope-private: no other scope may import it
The six types. A library may import its own layer and anything below it, never above. Only feature and shell libraries are private to their scope; everything else is reachable from any domain.

Today the census is 13 feature, 10 ui, 11 data-access, 6 testing, 1 util and 1 shell library. Two consequences of the table carry most of the structure:

  • A feature never imports another feature. When a screen in one domain needs something from another domain, the thing it needs moves down into that domain's ui or data-access library. Features become leaves, and a leaf can be lazy-loaded, tested and deleted on its own.
  • shared imports only shared. The day a shared library imports a domain it is no longer shared. Domain models stay home, in the domain's data-access, and other domains import them from there.
challenge

Our first table made every scope private: a domain could reach only itself and shared. Anything two domains needed had to be lifted into shared, which grew to a quarter of all library code and made almost every test suite "affected" by any change.

how we solved it

Only feature and shell stay private. Every other type is open to every scope, and the type rows alone stop a feature from leaking. Models moved back to their domains, one API class per domain replaced a 24-method façade, and the cross-domain data edges became a short, one-way list.

03. The app holds almost nothing

The application's src/ is flat: the bootstrap file, the root component, authentication configuration, global styles with the design tokens, translation files, assets and environment files. It has no app/ folder and no code folder of any kind.

That is deliberate. An app folder is the one place the boundaries rule cannot help with: the app may import anything, so anything put there is code nobody can reuse and nobody can fence. The rule we work by is short:

A new domain is a library, never a folder in the app.

Libraries never import the app either. A library that needs the environment or runtime configuration reads it through an injection token that the bootstrap file provides, so the library compiles and tests without the app existing at all.

04. Feature libraries: sliced by screen

A feature library is the routed screens of one domain or one area of it. It is lazy-loaded from the shell's root routes and is never imported statically. Its top level answers one question: which screens are in here?

before: sorted by kindsrc/lib/
  components/
  services/
  models/
  resolvers/
  utils/
  orders.routes.ts
which screens exist?
open every folder to find out
after: sliced by screensrc/lib/
  orders.routes.ts
  order-list/
  order-details/
  order-edit/
  cancel-order-dialog/  ← shared thing
  orders.service.ts     ← two slices
  order-view.model.ts

The top level of a feature library may contain only four kinds of entry:

  • The routes file, which also exports the domain's providers array (more on that below).
  • A slice: one folder per screen or use case, named for what the user does there.
  • A flat file two slices share: a feature service, a resolver, a view model.
  • A folder named for a shared thing: a dialog that three screens open, a history panel two screens render. It is named for the thing, never for its kind.

What may not appear is a technical folder (components, services, models, resolvers, guards, utils, dialogs, pipes and so on) or a Feature-Sliced Design segment (entities, model, api, lib and so on). A spec fails the build if one shows up.

Providers live with the routes

There are no NgModules. Each lazy domain's routes file exports its routes and an <domain>Providers array, registered on a component-less root route. That gives one injector per lazy-load site, which is what the old feature module gave. A domain service is listed there rather than provided in root, so it lives and dies with its screens. The exceptions are few and named: a service a root-level dialog injects, the cross-domain API classes, and the genuinely app-wide shared services.

05. Inside a slice

one sliceorder-details/
  order-details.component.*              the routed component, flat
  order-details.resolver.ts              only this slice reads it
  order-details.model.ts
  order-timeline/                        a sub-component keeps its folder
  ui/                                    small dumb components may share one
    status-badge/
    price-line/
  • The routed component's files sit flat at the slice root.
  • The resolver, guard, service, model or helper that only this slice reads sits flat beside it.
  • A sub-component keeps its own folder inside the slice.
  • When something gets a second reader in another slice, it moves up to the library root, flat or in a folder named for it.
  • When something gets a reader in another domain, it moves out of the feature library altogether, into the domain's ui or data-access.
  • A service's interfaces live in a sibling *.model.ts, never inside the service file.

The rule of thumb is that a file lives as close to its readers as possible and moves exactly one level when it gets a new kind of reader. A spec checks the upward direction too: a file at the library root whose importers all sit in one slice fails, because it belongs in that slice.

06. Data-access: organised by kind

Slicing by screen is right for features and wrong for the data layer. A data-access library holds what other code injects or types against: entity models, the domain's API class, its store. A service layer really is organised by kind, so the technical folders stay, and they are a closed list:

a data-access librarysrc/lib/
  models/     one file per entity
  services/   the domain's API class, held-state services
  store/      state, actions, selectors, the store facade
  consts/
  utils/
challenge

One wave put a domain's store inside its feature library, because only that feature read it. It looked tidy. The day a second screen in another library needed the same state, the store would have had to move under deadline pressure.

how we solved it

A store lives in the domain's data-access library whatever the consumer count. The feature's routes file registers it through the library's alias. Any class whose job is held state follows the same rule. It removes a judgement call, and the cost is one import line.

This is stricter than some published guidance, which keeps a one-consumer store inside the feature. Both satisfy the same layer order; for a team, a rule without a "how many readers?" question is easier to follow and to check.

07. ui, util, testing, shell

TypeHoldsTop level of src/lib
uicomponents another domain renders, including a routeless widgetone folder per component; shared Sass partials in a styles/ folder at the library root
utildomain-free models and pure helperstechnical folders and flat files
testinga scope's test generators and fixturesanything; imported by spec files only
shellroot routes, guards, interceptors, layouttechnical folders: components/ services/ guards/ interceptors/

Three rules here are worth calling out because each one came from a failure:

  • A widget with no route is never a feature. If another domain renders it, it is ui plus data-access. A feature library that another domain imports breaks lazy loading and the boundaries at the same time.
  • Generators are their own library, not a testing/ folder. Nx counts a spec's import as a dependency. A generator folder inside a production library dragged fixtures into its production graph; a separate type:testing library, reachable only from spec files, does not.
  • A shared Sass partial lives in a ui library's styles/ folder and is reached by a load-path import, never by ../../... The project graph cannot see Sass imports, so a spec checks them instead.

08. Where does it go? A decision guide

This is the table we actually use, both in code review and in the instructions our AI agents read before creating a file. Start from the first row that matches.

You are adding…It goes in
a new business domaina new scope: libs/<domain>/feature plus data-access, never a folder in the app
a new routed screena new slice in the domain's feature library, plus a route in its routes file
a component only that screen usesa sub-folder inside the slice
a component another domain rendersthe domain's ui library
a service for one screenflat in the slice, listed in the domain's providers array
a service two screens of the same feature shareflat at the feature library root
an entity model, or an HTTP API callthe domain's data-access: models/ or services/
a store, or any service that holds statethe domain's data-access, store/ or services/
a type that names two domainsthe downstream domain's data-access: the one that already depends on the other
a pure helper with no domainshared/util
a test generatorthe scope's testing library
a guard or interceptor for the whole appthe shell
a Sass partial two libraries usestyles/ in the owning ui library

The row about a type that names two domains is how cycles are prevented without an exception list. There are four such patterns, short enough to remember: layers point down only; a testing library depends on its domain's data-access and never the reverse; a type naming two domains lives downstream; and two features that need each other's screens share a ui library, never each other.

09. Imports and barrels

  • Between libraries, only the alias. @org/<scope>/<name>, never a path into another library's src, not even inside a test mock string.
  • Inside a library, only relative paths. A library never imports itself through its own alias.
  • A barrel exports only what another project imports. A feature library's barrel exports its routes and nothing else. A data-access barrel exports the models, API classes and store pieces other libraries use. The barrel is the library's public API, so it stays small on purpose.

Small barrels matter more than they look. Everything exported is something another library can start to depend on, and every dependency is an edge that nx affected has to follow. A narrow public API keeps the affected set narrow, which keeps CI fast.

10. What holds the shape

None of this survives on documentation alone, especially with AI agents writing a large share of the code. Each rule above is held by something that fails:

CheckFails when
Boundaries lint, required in the PR pipelinean import crosses the layer table, forms a cycle, imports the app, statically imports a lazy library, or reaches a testing library from production code
Library-shape spec, one row per librarya feature library grows a technical folder; a data-access library grows a folder outside its list; shared imports a domain; a root file has readers in one slice only; the library count drops below its floor
Provider census specan injectable with no providedIn is listed in no providers array
Sass-edge speca stylesheet import reaches into the app or into a feature library

The rules a spec cannot check (slice names, a file in the wrong slice, a technical folder nested inside a slice) are checked on review. Any structural rule we add has to name the check that would fail if it were broken, or say plainly that none exists.

The library-count floor deserves a note. A check with a fixed number breaks the day someone legitimately deletes a library. A floor that only ever moves up catches the deletion that was not legitimate.

11. If you are starting one

  • Pick the types before the scopes. The type table is the architecture; the scopes are just the domains you already have.
  • Make only feature and shell libraries private to their scope. A private data layer pushes every shared model into shared, and shared becomes the monolith you were leaving.
  • Slice features by screen; keep the data layer by kind. The two layers answer different questions, so they get different shapes.
  • Keep the app empty. If the app has a code folder, someone will put a domain in it.
  • Write the "where does it go" table down and give it to people and agents alike. Most structural drift is somebody who did not know which row applied.
  • Back every rule with a check. A layout described only in a document lasts about one busy sprint.

A good folder structure answers "where does this go?" before anyone asks it.

The story of how we got here (twelve days, ten pull requests, the rules we reversed and the numbers) is in the migration case study.