How I Structure Large Angular Projects (After Learning the Hard Way)

May 24, 2026 (3w ago)

thumbnail

After working on Clevylinks — a logistics platform with complex workflows, real-time data, and multiple user roles — I've learned a lot about what makes a large Angular codebase survivable. Not from tutorials. From actual pain.

This post is my honest reflection on the structure I've landed on, and why.

The Problem With "Just Wing It"

When I first joined the project, the codebase was already growing fast. Features were being added, components were being created wherever was convenient, and services were doing way too much.

The symptoms were familiar:

Sound familiar? Here's how I started fixing it.

The Folder Structure That Actually Works

src/
├── app/
│   ├── core/           # Singleton services, guards, interceptors
│   ├── shared/         # Reusable components, pipes, directives
│   ├── features/       # Feature modules (lazy loaded)
│   │   ├── shipment/
│   │   ├── tracking/
│   │   └── dashboard/
│   └── layout/         # Shell components (navbar, sidebar, footer)
├── assets/
└── environments/

The key insight: separate by responsibility, not by type.

Most beginner projects group by type — a components/ folder with 50 components, a services/ folder with 30 services. That scales terribly. Instead, group by feature, and within each feature, by type.

features/
└── shipment/
    ├── components/
    │   ├── shipment-list/
    │   └── shipment-detail/
    ├── services/
    │   └── shipment.service.ts
    ├── models/
    │   └── shipment.model.ts
    ├── store/           # NgRx state if needed
    └── shipment.module.ts

Now when someone says "fix the shipment list bug," you know exactly where to look.

Core vs Shared: Don't Mix Them Up

This distinction tripped me up early. Here's the rule:

core/ — things that are instantiated once for the whole app.

shared/ — things that are reused across multiple features but have no business logic.

A SharedModule should never import from CoreModule. A FeatureModule can import from both.

If a component in shared/ needs to call an API, you've put it in the wrong place.

Lazy Loading Is Not Optional

For a large app, every feature module should be lazy loaded. Full stop.

// app-routing.module.ts
const routes: Routes = [
  {
    path: 'shipments',
    loadChildren: () =>
      import('./features/shipment/shipment.module').then(m => m.ShipmentModule),
  },
  {
    path: 'tracking',
    loadChildren: () =>
      import('./features/tracking/tracking.module').then(m => m.TrackingModule),
  },
];

At Clevylinks, initial bundle size was bloated because everything was eagerly loaded. Switching to lazy loading per feature cut our initial load time significantly. Users on the dashboard don't need the shipment creation code until they navigate there.

Keep Services Focused

The biggest mistake I see: one mega-service that does everything for a domain.

// ❌ ShipmentService doing too much
class ShipmentService {
  getShipments() { ... }
  createShipment() { ... }
  formatTrackingNumber() { ... }  // presentation logic
  calculateShippingCost() { ... } // business logic
  exportToCsv() { ... }           // unrelated concern
}

Break it apart:

// ✅ Focused services
class ShipmentApiService { ... }    // only HTTP calls
class ShipmentHelperService { ... } // formatting, calculations
class ShipmentExportService { ... } // export concerns

Each service should have one reason to change.

Typed Models — No any, No Exceptions

Logistics data is complex: shipments have statuses, packages have dimensions, routes have waypoints. TypeScript earns its keep here.

export type ShipmentStatus = 'pending' | 'in_transit' | 'delivered' | 'failed';
 
export interface Shipment {
  id: string;
  status: ShipmentStatus;
  origin: Address;
  destination: Address;
  packages: Package[];
  createdAt: Date;
}

When the API response doesn't match the model, you want TypeScript to scream at compile time — not your users to see broken UI at runtime.

State Management: Only When You Need It

Not every feature needs NgRx. I've seen projects that NgRx everything and end up with 3x the boilerplate for simple CRUD screens.

My rule of thumb:

At Clevylinks, real-time shipment tracking uses NgRx because multiple components subscribe to the same stream and the state transitions are complex. But the basic settings page? Just a service with local state.

Interceptors Are Your Best Friend

All the cross-cutting concerns — auth headers, error handling, loading spinners — belong in HTTP interceptors, not scattered across services.

@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  intercept(req: HttpRequest<unknown>, next: HttpHandler) {
    const token = this.authService.getToken();
    const cloned = req.clone({
      headers: req.headers.set('Authorization', `Bearer ${token}`),
    });
    return next.handle(cloned);
  }
}

One interceptor. Every request gets the auth header. No service needs to think about it.

What I'd Tell Myself at the Start

  1. Start with lazy loading. Retrofitting it later is painful.
  2. Define your models before writing services. Know the shape of your data first.
  3. shared/ has no business logic. If it needs a service, it's a feature, not shared.
  4. Keep components dumb. They display data and emit events. Logic lives in services.
  5. Review your imports regularly. Circular dependencies creep in silently.

A well-structured Angular app doesn't feel like Angular is fighting you — it feels like it's helping you. It took real production pain to get there, but it's worth it.


If you're building something complex with Angular, start with the structure. Everything else — performance, testability, maintainability — follows from it.

Happy structuring.