Angular applications typically oscillate between two dominant architectural models: layered architecture and feature-based architecture. Many developers present them as opposing, even incompatible. In reality, it's a false debate. The real issue is understanding the hidden costs of each approach and how to combine them to prevent your codebase from becoming a mess of circular dependencies or an impenetrable maze of folders.
Layered: The Classical Structure That Reassures
Layered architecture—controllers, services, models, views—dominated for two decades for one simple reason: it's predictable. Each layer has a clear role. A service calls a repository, which queries the API. A component injects the service and displays data. It's hierarchical, easy to explain to junior developers, and unit tests naturally align with this separation.
But this clarity hides fragility. As the app grows, services become dumping grounds. An authentication service ends up managing auth, permissions, session handling, security logs, and sometimes navigation. High-level components end up importing low-level services without passing through intermediate layers. Cross-cutting dependencies appear. You end up with a dependency graph no one truly understands, where modifying one service risks breaking three features across the codebase.
The real problem isn't the layered structure itself; it's the absence of functional boundaries. A 50,000-line app with 15 shared services is hard to maintain because nobody really knows who depends on whom, and changes propagate like an epidemic.
Feature-Based: Slicing by Business Responsibility
Feature-based architecture organizes code around functionality: a /cart folder, a /checkout folder, a /user-profile folder. Each feature contains its components, services, models, pipes, validators. It's a mini-module that stands alone.
The advantage? One team can work on /cart without touching /checkout . A new developer can understand a feature by reading a single folder. Tests are localized. If you delete a feature, you delete a folder—done. No phantom traces in shared services.
Concretely, your structure looks like this:
src/app/
/features/
/cart/
/components/
/services/
/models/
/cart.module.ts
/checkout/
/components/
/services/
/models/
/checkout.module.ts
/shared/
/components/
/services/
/models/
/core/
/auth.service.ts
/http-interceptors/
But feature-based creates its own trap: duplication. Two features need a paginated list component? You copy-paste it, or create a shared service? If it's a shared service, you reintroduce coupling. If it's a copy, your maintenance gets complicated.
The Hybrid Model: The Real Wisdom
The best Angular projects blend both approaches. You keep a /core layer for what's genuinely cross-cutting: guards, interceptors, auth, logging. You keep a /shared layer for components and utilities serving multiple features. Everything else is organized by feature.
The key is discipline: /core depends on nothing. /shared depends on /core , but never on /features . Each feature depends on /core and /shared , but never on other features. You create a strict import hierarchy that your linter can enforce with eslint-plugin-nx or eslint-plugin-import .
Here's a concrete example with two features: cart and checkout. Cart manages adding/removing items. Checkout uses the cart to fetch the total.
// cart/services/cart.service.ts
//@Injectable()
//@providedIn: 'root'
export class CartService {
/constructor(private http: HttpClient) {}
/addItem(product: Product): Observable<Cart> { ... }
/getTotal(): Observable<number> { ... }
}
// checkout/services/checkout.service.ts
//@Injectable()
export class CheckoutService {
/constructor(private cartService: CartService) {}
/processPayment(amount: number): Observable<PaymentResult> {
/return this.cartService.getTotal().pipe(
/switchMap(total => this.http.post('/api/payment', { amount: total }))
/);
/}
}
Checkout depending on cart is normal business logic. But if cart needed checkout in return, you'd have a circular dependency. A /shared layer with a CartStateService based on Signals or NgRx breaks that cycle: cart emits its state, checkout listens to it, no direct dependency.
Common Pitfalls
Pitfall number one: believing a chosen architecture solves design problems. A poorly-designed feature-based app remains chaos. Features calling each other directly, shared services that are only shared in name, circular dependencies that explode at compile time. Architecture is just the container; discipline is the content.
Second pitfall: over-engineering shared code. You create a /shared folder with 50 components, and only 5 are actually used. The rest creates friction. Prefer creating shared code when you need it, not in anticipation.
Third pitfall: confusing feature with module. A feature isn't necessarily a lazy-loadable module. You can have a feature in /shared that loads at initialization, and a feature in /features that's lazy-loaded. Folder structure and loading strategy are two independent decisions.
Practical Strategy for Your Next Project
Start feature-based for business elements (cart, user account, search). Maintain /core for infrastructure (auth, http, config). Build /shared as needed, not all at once. Use a linter to enforce dependency hierarchy. And crucially, document why you put something in /shared : "Used by 3+ features" is good; "We're not sure" is bad.
For distributed teams or large apps, feature-based wins on clarity. For a startup with a 20 KLOC monolithic app, layered can suffice if you stay disciplined. The choice depends on your team, not a blog post.
Real architecture is the one your team understands, maintains, and improves without friction. Not the one that looks coolest on Hacker News.