logo
2026-04-16 Angular 7 min

Vertical Slicing and the Architecture Matrix: Scaling Angular Codebases

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:

The matrix for a flight booking app might look like this:

booking

checkin

shared

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:

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:


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.