Every Angular codebase starts small. A few components, a service or two, maybe a shared module. Then the app grows, the team grows, and one day you realize nobody can explain which folder owns what. Features bleed across boundaries. A “quick change” in one module breaks something three directories away. You’ve got a monolith with no structure.
I’ve been through this cycle multiple times across different projects. On a healthcare SaaS platform, where I built a separate Nx monorepo of module-federated microfrontends that plugged into the existing monolith — structured with vertical slicing from day one to avoid the organizational problems we’d already lived through. When you’re the one who has to decide which shared library goes where in the Nx workspace, abstract advice about “finding boundaries” isn’t enough. You need a system. The one that’s worked best for me is a combination of vertical slicing by business domain and horizontal layers with strict dependency rules. I call it the Architecture Matrix, and it’s how I structure every serious Angular app now.
The Problem with Flat Feature Modules
The classic Angular approach is to organize by feature module: users/, dashboard/, settings/. It works fine until features start sharing things. Then you get a shared/ folder that becomes a dumping ground. Services import from everywhere. Circular dependencies show up. Nobody knows where to put the next piece of code.
The root issue is that feature modules only give you one dimension of organization. You know what the feature is, but you have no rules about how code within that feature should be layered or what it’s allowed to depend on.
The Architecture Matrix
The Architecture Matrix adds that missing dimension. You slice your app vertically by business domain and horizontally by layer type. Each intersection is a module with clear rules about what it can access.
The vertical slices are your domains. In a healthcare SaaS platform I worked on, those domains were things like patient-management, billing, treatment-planning, and insurance-claims. They map to real business capabilities, not UI screens.
The horizontal layers are:
- feature — Smart components, routed pages, orchestration logic. This is where user-facing behavior lives.
- ui — Presentational (dumb) components. No injected services, no side effects. Pure inputs and outputs.
- data — Services, stores, API clients. Everything related to fetching, caching, and transforming data.
- util — Pure functions, validators, type guards, constants. Zero Angular dependencies.
The matrix for a flight booking app might look like this:
booking
feature— search page, results pageui— flight card, date pickerdata— booking API, booking storeutil— price formatter
checkin
feature— checkin wizardui— seat map, boarding passdata— checkin API, checkin storeutil— seat validator
shared
feature— —ui— header, footer, layoutdata— auth service, user storeutil— date utils
Every domain is a vertical slice. Every layer within it is a module with a clear purpose and a clear set of allowed dependencies.
The Dependency Rules
Three rules govern the entire system:
1. A domain can only access its own domain or shared. The booking domain never imports from checkin. If both need something, it goes in shared. This is non-negotiable.
2. Modules can only access same-layer or lower layers. The hierarchy is: feature > ui > data > util. A feature module can import from ui, data, or util in the same domain. A data module can import from util. But util never imports from data, and ui never imports from feature.
3. Only public interfaces are accessible. Each module exposes a deliberate public API. Internal implementation details stay internal.
These three rules prevent circular dependencies by design, keep coupling low, and make the codebase navigable even when you have dozens of domains.
Finding Your Domain Boundaries
This is where most teams get it wrong. They slice by UI screens or by database tables instead of by business capability. I lived this on a past project — establishing an Nx monorepo and re-architecting into microfrontends forced us to find real domain boundaries, not just convenient folder names.
I use a lightweight version of DDD Strategic Design to find boundaries. Three questions help:
Language. Do different parts of the organization use different words for the same concept? On one project, “claim” meant one thing to the billing team (a charge against an insurance payer) and something entirely different to the clinical team (a treatment plan submitted for approval). That ambiguity was a clear domain boundary.
Responsibilities. If a business capability could theoretically be outsourced to a different team without breaking everything else, it’s probably its own domain.
Pivotal events. Look for events that change the state of the system in ways that multiple parts care about. “Booking confirmed” is a pivotal event. The domains on either side of that event are likely separate.
Don’t overthink it. Start with 3-5 domains and split later when the pain shows up.
Folder Structure
Here’s what the matrix looks like on disk:
src/
app/
booking/
feature/
search-page/
results-page/
ui/
flight-card/
date-picker/
data/
booking.store.ts
booking-api.service.ts
util/
price-formatter.ts
checkin/
feature/
checkin-wizard/
ui/
seat-map/
boarding-pass/
data/
checkin.store.ts
checkin-api.service.ts
util/
seat-validator.ts
shared/
ui/
header/
footer/
layout/
data/
auth.service.ts
user.store.ts
util/
date-utils.ts
The pattern is always domain/layer/artifact. Once you internalize it, you never have to think about where a file goes.
Enforcing the Rules with Sheriff
Rules that only exist in documentation get broken. You need tooling. I use Sheriff, an ESLint-based tool that enforces dependency rules based on file tags.
First, enable barrel-less mode. This is important and I’ll explain why in a moment:
// sheriff.config.ts
import { spikeConfig } from '@softarc/sheriff-core';
export const config = spikeConfig({
enableBarrelLess: true,
modules: {
'src/app/<domain>/feature': ['domain:<domain>', 'layer:feature'],
'src/app/<domain>/ui': ['domain:<domain>', 'layer:ui'],
'src/app/<domain>/data': ['domain:<domain>', 'layer:data'],
'src/app/<domain>/util': ['domain:<domain>', 'layer:util'],
},
depRules: {
'layer:feature': ['layer:ui', 'layer:data', 'layer:util', 'domain:shared'],
'layer:ui': ['layer:util', 'domain:shared'],
'layer:data': ['layer:util', 'domain:shared'],
'layer:util': ['domain:shared'],
},
});
The tag-based approach is clean. Each module gets a domain tag and a layer tag. Dependency rules reference tags, not file paths. Adding a new domain requires zero rule changes.
Why Barrel Files Hurt Modern Angular
I set enableBarrelLess: true above for a reason. Barrel files actively harm modern Angular applications.
A barrel file (index.ts) re-exports everything from a module. The problem is that bundlers must evaluate the entire barrel to resolve a single import. This breaks tree-shaking because the bundler can’t statically determine which exports are unused when they’re funneled through a re-export. It also breaks lazy loading because importing one thing from a barrel pulls in the entire module’s dependency graph.
The better approach is convention-based encapsulation. Instead of barrels, use an internal/ folder:
booking/
data/
internal/
booking-api.mapper.ts
booking-cache.service.ts
booking.store.ts
booking-api.service.ts
Files inside internal/ are off-limits to other modules. Files outside internal/ are the public API. Sheriff enforces this automatically with enableBarrelLess: true — no barrel files needed, no re-export chains, and the bundler can do its job properly.
Lightweight Path Mappings
You don’t want imports that look like ../../../booking/data/booking.store. A single tsconfig path mapping covers all domains:
{
"compilerOptions": {
"paths": {
"@app/*": ["src/app/*"]
}
}
}
Now imports are clean and stable:
import { BookingStore } from '@app/booking/data/booking.store';
One mapping. All domains. No maintenance overhead.
Store Placement and Data Flow
Where you put your stores matters. I follow a simple rule:
- Feature stores live in
feature/and hold state local to that feature. A search page’s filter state, pagination cursor, selected sort order — these belong in a feature store. - Domain stores live in
data/and hold state that multiple features within the domain need. The list of bookings, the current user’s booking history — these belong in a data-layer store.
Data flow is always unidirectional. Events go up from components to stores. The store processes the event and updates state. State flows down to components via signals or observables. Components never mutate state directly.
This creates a predictable cycle: user action -> event -> store processes -> state updates -> UI re-renders.
Store-to-Store Communication
Eventually, one store needs data from another. There are three patterns, ranked by my preference:
Orchestration service (most practical). A service in the feature layer coordinates between stores. The feature’s smart component calls the orchestration service, which calls the relevant stores in sequence. Stores stay independent. This is what I reach for first.
Eventing (cleanest separation). Stores communicate through an event bus or a shared signal. Store A emits an event. Store B reacts to it. Neither knows the other exists. Beautiful in theory, harder to debug.
Direct access (acceptable with discipline). Store A injects Store B directly. This works when the layering rules prevent cycles — a feature-layer store can access a data-layer store in the same domain without creating architectural problems. Just don’t let data-layer stores access each other.
Conway’s Law Works Both Ways
Here’s something that took me years to appreciate: your architecture will mirror your team structure whether you want it to or not. That’s Conway’s Law.
The smart move is to use it deliberately. This is the Inverse Conway Maneuver — structure your teams to match the architecture you want. If you want clean domain boundaries, assign teams to domains. The booking team owns the booking slice. The checkin team owns the checkin slice. Shared is owned by a platform team.
This means different teams can ship independently. Merge conflicts drop. Code reviews stay focused. The architecture enforces itself through organizational structure.
A few practical notes on scaling:
- Moduliths work best for 1-2 teams. If your entire frontend is one team, the Architecture Matrix in a single deployable gives you all the structure you need without the overhead of separate builds.
- Micro frontends only make sense for multi-team, multi-year systems. I moved one platform to module federation because we needed independent tools that plugged into a main dashboard, with shared auth state and a design system distributed as an npm package. That justified the overhead. If you have one app and one team, a modulith is the right call. Don’t reach for micro frontends prematurely.
- Frontend and backend can slice differently. Your backend might be organized around data aggregates while your frontend is organized around user journeys. That’s fine. Use a BFF (Backend for Frontend) layer to bridge the two models.
Final Thoughts
The Architecture Matrix isn’t a framework. It’s a set of constraints. Vertical slices keep domains independent. Horizontal layers keep code organized within a domain. Three dependency rules prevent the chaos. Sheriff enforces it all automatically.
The result is a codebase where every file has an obvious home, every dependency is intentional, and new team members can navigate the structure in their first week. When I led an Angular 17 to 21 migration on a large-scale platform, this architecture was the reason we could upgrade incrementally, domain by domain, instead of doing a risky big-bang migration. The boundaries we’d drawn years earlier paid for themselves in a single quarter. That’s what scaling an Angular codebase actually looks like.