Building an Angular app that works is relatively simple. Building an Angular app that stays tidy, readable, scalable and easy to maintain after months or years of development is a different matter entirely. The real difference between an amateur project and a professional one isn't just code that “does its job”, but the architecture that lets the team keep developing without creating chaos.
As an application grows, so do pages, components, services, HTTP calls, state to manage, permissions, routes, forms, validations and dependencies between the various parts of the system. If everything goes into generic folders such as components, services and models, it soon becomes hard to understand where a feature lives, who uses what and which parts of the code can be changed without breaking the rest.
In this guide we look at how to structure a modern Angular app using a feature-based architecture, a clear separation between core and shared, standalone components, signals, the smart vs dumb components pattern and a folder structure designed for real projects.
Why architecture matters in Angular
Angular is a very powerful framework because it already offers a clear structure: components, services, dependency injection, routing, forms, an HTTP client, guards, interceptors and much more. However, precisely because Angular offers so many tools, it is easy to use them without a coherent strategy.
A small project can survive even with a messy structure. A medium or large project, on the other hand, needs rules. Without rules, every developer organises code their own way, components grow too large, services pile up unrelated responsibilities and the project slowly turns into a block that is hard to change.
A good Angular architecture should help achieve a few fundamental goals:
- Scalability: adding new features without having to reorganise the whole project.
- Maintainability: quickly understanding where the code is and how to change it.
- Separation of concerns: every file, component or service must have a clear role.
- Testability: the code must be easy to test in isolation.
- Performance: the structure should favour lazy loading, smaller bundles and efficient rendering.
- Collaboration: several developers must be able to work on the same app without stepping on each other's toes.
The key point is this: architecture is not there to complicate the project, but to make it more predictable. When a structure is predictable, every new feature has a natural place to live.
Feature-based architecture: organising code by feature
One of the most common mistakes in Angular projects is organising code by technical type instead of functional domain. A structure like this may look tidy at first:
src/app/
components/
services/
models/
pipes/
directives/
pages/
The problem is that this organisation says nothing about the product. If you are working on the projects section, you will have to look for components in components, services in services, models in models, pages in pages and so on. Every feature is scattered across the whole project.
A feature-based architecture, instead, organises code around the application's features. For example:
src/app/
features/
dashboard/
blog/
projects/
experiences/
auth/
admin/
Each folder represents a real part of the product. Everything about the blog lives in features/blog. Everything about projects lives in features/projects. Everything about the admin lives in features/admin.
This approach greatly improves the project's readability. When you need to change a feature, you know where to go. When you need to remove a feature, you know which files are involved. When a new developer joins the team, they can understand the app starting from its features rather than from generic technical folders.
A feature should be as self-contained as possible
A good Angular feature should contain everything it needs to work: pages, specific components, data-access services, models, local stores, routes and internal utilities.
Example structure for a projects feature:
src/app/features/projects/
projects.routes.ts
pages/
projects-list-page.component.ts
project-detail-page.component.ts
components/
project-card.component.ts
project-filters.component.ts
project-empty-state.component.ts
data-access/
projects-api.service.ts
projects-store.service.ts
models/
project.model.ts
project-filter.model.ts
utils/
project-status.util.ts
This structure makes the feature independent and easy to understand. Pages are separated from smaller components, data-access logic lives in data-access, TypeScript types are in models and the feature-specific helper functions are in utils.
Important rule: avoid dependencies between features
A feature should not directly import components, services or models from another feature. For example, features/blog should not import code from features/projects. This creates coupling and makes it hard to change one feature without affecting the others.
If two features need the same component or utility, that element probably needs to move to shared. If instead they share important domain logic, it can be useful to create a dedicated folder or library, but always with clear boundaries.
Core and Shared: a fundamental difference
In many Angular projects we find the core and shared folders, but they are often misused. Understanding the difference between these two areas is essential to keep the project clean.
Core: what belongs to the application as a whole
The core folder holds global code, used at application level and often initialised only once. Here you find things like authentication, HTTP interceptors, global guards, the main layout, error handling, configuration, singleton services and infrastructure logic.
Example:
src/app/core/
auth/
auth.service.ts
auth.guard.ts
auth.interceptor.ts
http/
api-error.interceptor.ts
http-context.tokens.ts
layout/
main-layout.component.ts
admin-layout.component.ts
config/
app-config.token.ts
guards/
role.guard.ts
services/
logger.service.ts
storage.service.ts
The core should not become a dumping ground for random services. It must contain only what is truly global. If a service is needed only by the blog feature, it doesn't go in core: it goes inside features/blog/data-access.
Shared: what is reusable and free of specific business logic
The shared folder holds elements that are reusable across several parts of the application but not tied to a specific feature. Here you find generic UI components, pipes, directives, helpers and truly shared models.
src/app/shared/
ui/
button/
modal/
card/
badge/
spinner/
pipes/
truncate.pipe.ts
safe-html.pipe.ts
directives/
autofocus.directive.ts
click-outside.directive.ts
utils/
date-format.util.ts
string.util.ts
models/
pagination.model.ts
api-response.model.ts
A component such as app-button, app-modal or app-spinner can live in shared/ui. A component such as project-card, instead, shouldn't be in shared, because it belongs to the projects domain.
Common mistake: putting everything in Shared
One of the most frequent anti-patterns is creating a huge shared folder that holds anything and everything. It seems convenient at first, but after a few months it becomes impossible to tell which components are truly generic and which were put there just for convenience.
The practical rule is simple: if a component contains words, logic or concepts tied to a specific feature, it isn't shared. If instead it is generic, reusable and independent of the domain, it can go in shared.
Standalone components: modern Angular without unnecessary NgModules
In modern Angular, standalone components have become the recommended way to build simpler, more explicit and more modular applications. In the past, every component had to be declared inside an NgModule. This often led to very large or unclear modules.
With standalone components, each component declares its own dependencies directly through the imports property. This makes the code more readable: by looking at a component you can immediately see which other components, directives or pipes it uses.
@Component({
selector: 'app-project-card',
standalone: true,
imports: [DatePipe],
template: `
<article class="project-card">
<h3>{{ project().title }}</h3>
<p>{{ project().description }}</p>
<small>Pubblicato il {{ project().createdAt | date }}</small>
<button type="button" (click)="open.emit(project().id)">
Apri progetto
</button>
</article>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectCardComponent {
project = input.required<Project>();
open = output<string>();
}
This approach has several advantages:
- dependencies are explicit;
- components are easier to move and test;
- lazy loading becomes more natural;
- there is less need for intermediate modules;
- the project is lighter to reason about.
Bootstrapping the app with standalone
In a modern Angular app, bootstrapping can be handled without a traditional AppModule. The global configuration is often defined in app.config.ts.
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withInterceptors([
authInterceptor,
apiErrorInterceptor
]))
]
};
And in the main.ts file:
bootstrapApplication(AppComponent, appConfig)
.catch((error) => console.error(error));
This configuration clearly separates the app's starting point from feature logic.
Routing and lazy loading by feature
Routing is a central part of Angular architecture. In a scalable project, each feature should have its own routes, lazy-loaded whenever possible.
In the main app.routes.ts file we can define only the top-level routes:
export const routes: Routes = [
{
path: '',
loadComponent: () =>
import('./features/home/pages/home-page.component')
.then((m) => m.HomePageComponent)
},
{
path: 'projects',
loadChildren: () =>
import('./features/projects/projects.routes')
.then((m) => m.PROJECTS_ROUTES)
},
{
path: 'blog',
loadChildren: () =>
import('./features/blog/blog.routes')
.then((m) => m.BLOG_ROUTES)
},
{
path: 'admin',
canMatch: [authGuard],
loadChildren: () =>
import('./features/admin/admin.routes')
.then((m) => m.ADMIN_ROUTES)
}
];
Inside the projects feature, instead, we define the specific routes:
export const PROJECTS_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./pages/projects-list-page.component')
.then((m) => m.ProjectsListPageComponent)
},
{
path: ':id',
loadComponent: () =>
import('./pages/project-detail-page.component')
.then((m) => m.ProjectDetailPageComponent)
}
];
This approach keeps the main routes file clean and lets Angular load only the code needed for the section the user visits.
Signals: simpler, more reactive state management
Signals are one of the most important tools in modern Angular. They let you manage local and derived state in a way that is simpler, more readable and more efficient than many traditional solutions.
A signal represents a reactive value. When the value changes, Angular knows which parts of the UI need to be updated. This makes state more explicit and reduces complexity in many scenarios.
Simple example:
const count = signal(0);
const double = computed(() => count() * 2);
function increment(): void {
count.update((value) => value + 1);
}
In a scalable app, signals can be used at several levels:
- local component state, such as the active tab, filters, or whether a modal is open;
- derived state, such as total items, filtered items, empty state;
- feature stores, when a section of the app has data shared between several components;
- facades, to expose simple, ready-to-use state to the UI.
Example of a store with signals
A good practice is to create a store specific to the feature, rather than putting all the logic inside components.
@Injectable()
export class ProjectsStore {
private readonly api = inject(ProjectsApiService);
private readonly _projects = signal<Project[]>([]);
private readonly _loading = signal(false);
private readonly _error = signal<string | null>(null);
private readonly _query = signal('');
readonly projects = this._projects.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
readonly query = this._query.asReadonly();
readonly filteredProjects = computed(() => {
const query = this._query().toLowerCase().trim();
if (!query) {
return this._projects();
}
return this._projects().filter((project) =>
project.title.toLowerCase().includes(query)
);
});
readonly total = computed(() => this.filteredProjects().length);
async loadProjects(): Promise<void> {
this._loading.set(true);
this._error.set(null);
try {
const projects = await firstValueFrom(this.api.getProjects());
this._projects.set(projects);
} catch {
this._error.set('Impossibile caricare i progetti.');
} finally {
this._loading.set(false);
}
}
setQuery(query: string): void {
this._query.set(query);
}
}
This store has a clear responsibility: managing the state of the projects feature. The component doesn't need to know how data is loaded or filtered. It only reads signals and calls methods exposed by the store.
Where to provide a feature store
If the store is needed by one specific feature only, it can be provided at route or component level. That way its lifecycle is tied to the feature itself instead of staying global for no reason.
export const PROJECTS_ROUTES: Routes = [
{
path: '',
providers: [ProjectsStore, ProjectsApiService],
loadComponent: () =>
import('./pages/projects-list-page.component')
.then((m) => m.ProjectsListPageComponent)
}
];
This choice avoids filling the root injector with services that don't need to live for the entire lifetime of the application.
Signals and RxJS: they are not enemies
A common mistake is to think that signals completely replace RxJS. In reality, the two tools can coexist perfectly well. Signals are great for synchronous state, derivations and UI state. RxJS remains very useful for asynchronous flows, complex events, WebSockets, debounce, retry, stream combinations and automatic request cancellation.
A useful rule of thumb:
- use signals to represent the current state of the UI;
- use computed for derived values;
- use RxJS when you need to model asynchronous flows over time;
- use conversions such as
toSignalwhen you want to expose an Observable to the template more simply.
Smart vs Dumb Components
The smart vs dumb components pattern is one of the most useful for keeping an Angular app clean. The idea is to separate components that handle logic, data and communication from those that only display information.
Smart components
Smart components, also called container components, are aware of the feature. They can use services, stores, the router, route parameters and application logic. They usually correspond to a page or to a feature's main component.
Example of a smart component:
@Component({
selector: 'app-projects-list-page',
standalone: true,
imports: [
ProjectCardComponent,
ProjectFiltersComponent,
SpinnerComponent
],
template: `
<section>
<h2>Progetti</h2>
<app-project-filters
[query]="store.query()"
(queryChange)="store.setQuery($event)"
/>
@if (store.loading()) {
<app-spinner />
} @else if (store.error()) {
<p class="error">{{ store.error() }}</p>
} @else {
<div class="projects-grid">
@for (project of store.filteredProjects(); track project.id) {
<app-project-card
[project]="project"
(open)="openProject($event)"
/>
}
</div>
}
</section>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectsListPageComponent implements OnInit {
readonly store = inject(ProjectsStore);
private readonly router = inject(Router);
ngOnInit(): void {
this.store.loadProjects();
}
openProject(id: string): void {
this.router.navigate(['/projects', id]);
}
}
This component is smart because it coordinates the page: it loads data, reads the store, handles navigation and passes information to child components.
Dumb components
Dumb components, also called presentational components, are simple, reusable and easy to test. They receive data through inputs and emit events through outputs. They shouldn't know about the router, HTTP services or global stores.
@Component({
selector: 'app-project-filters',
standalone: true,
template: `
<label>
Cerca progetto
<input
type="search"
[value]="query()"
(input)="onInput($event)"
placeholder="Cerca per titolo..."
/>
</label>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectFiltersComponent {
query = input('');
queryChange = output<string>();
onInput(event: Event): void {
const target = event.target as HTMLInputElement;
this.queryChange.emit(target.value);
}
}
This component knows nothing about the feature as a whole. It doesn't know where projects come from, doesn't know the API, doesn't navigate and doesn't change the store directly. Its only job is to show an input and communicate the new value.
Why this separation works
Separating smart and dumb components brings concrete benefits:
- presentational components are easier to reuse;
- tests become simpler;
- application logic stays in containers or stores;
- the UI becomes more predictable;
- the risk of huge, hard-to-maintain components is reduced.
However, the pattern shouldn't be applied dogmatically. For very small components or simple features, a single component may be enough. The goal isn't to create as many files as possible, but to keep responsibilities clear.
Recommended folder structure for a scalable Angular app
A solid structure for a modern Angular app could be this:
src/
app/
app.component.ts
app.config.ts
app.routes.ts
core/
auth/
auth.service.ts
auth.guard.ts
auth.interceptor.ts
http/
api-error.interceptor.ts
layout/
main-layout.component.ts
admin-layout.component.ts
services/
logger.service.ts
storage.service.ts
shared/
ui/
button/
button.component.ts
modal/
modal.component.ts
spinner/
spinner.component.ts
pipes/
truncate.pipe.ts
directives/
autofocus.directive.ts
models/
pagination.model.ts
api-response.model.ts
utils/
date.util.ts
features/
home/
pages/
home-page.component.ts
projects/
projects.routes.ts
pages/
projects-list-page.component.ts
project-detail-page.component.ts
components/
project-card.component.ts
project-filters.component.ts
data-access/
projects-api.service.ts
projects-store.service.ts
models/
project.model.ts
utils/
project-status.util.ts
blog/
blog.routes.ts
pages/
blog-list-page.component.ts
blog-detail-page.component.ts
components/
blog-card.component.ts
blog-search.component.ts
data-access/
blog-api.service.ts
blog-store.service.ts
models/
blog-post.model.ts
admin/
admin.routes.ts
pages/
admin-dashboard-page.component.ts
edit-post-page.component.ts
components/
admin-sidebar.component.ts
content-editor.component.ts
data-access/
admin-api.service.ts
admin-store.service.ts
models/
admin-user.model.ts
environments/
environment.ts
This structure is simple enough to be understood quickly, yet solid enough to grow over time.
The main folders explained
app.config.ts holds the global providers: router, HTTP client, interceptors, global configuration and application services.
app.routes.ts holds only the top-level routes and delegates the detailed routes to the individual features.
core holds what lives at global level: auth, interceptors, layout, logger, configuration and infrastructure services.
shared holds generic, reusable elements with no specific business logic.
features holds the heart of the application, organised by functional domain.
pages holds components tied directly to routes. They are usually smart components.
components holds feature-specific components, often dumb or semi-presentational.
data-access holds API services, stores, facades and data-access logic.
models holds the TypeScript interfaces and types tied to the feature.
utils holds pure functions specific to the feature.
Data-access layer: isolating APIs and state
In a scalable Angular app, components shouldn't talk directly to HttpClient. If every component makes its own HTTP calls, logic gets duplicated and it becomes hard to manage loading, errors, caching and data transformation.
It's better to create a data-access layer for each feature. This layer can contain:
- API services;
- signal-based stores;
- facades;
- mappers between DTOs and UI models;
- the feature's local caching logic.
Example of an API service:
@Injectable()
export class ProjectsApiService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/projects';
getProjects(): Observable<Project[]> {
return this.http.get<Project[]>(this.baseUrl);
}
getProjectById(id: string): Observable<Project> {
return this.http.get<Project>(`${this.baseUrl}/${id}`);
}
}
The component doesn't need to know endpoints, URLs or HTTP details. It should talk to the store or a facade. This keeps the UI clean and makes testing easier.
Practical dependency rules
To avoid architectural chaos, it is useful to set clear dependency rules:
- A feature can import from shared, because shared contains generic elements.
- A feature can use core services, such as auth or logger, if really needed.
- Shared must not import features, otherwise it stops being generic.
- Shared should avoid strong dependencies on core, to stay reusable.
- A feature should not directly import another feature.
- Core should not contain logic specific to a single feature.
One possible direction for dependencies is this:
features ---> shared
features ---> core
core ---> shared
shared ---> nessuna feature
The more these rules are respected, the more modular the app stays.
Recommended naming conventions
Naming conventions may seem like details, but in large projects they make a big difference. A consistent name reduces the time needed to understand a file's role.
*.page.tsfor components tied to a route.*.component.tsfor regular UI components.*.service.tsfor generic services.*.api.service.tsfor services that talk to the backend.*.store.tsor*.store.service.tsfor feature state.*.guard.tsfor route guards.*.interceptor.tsfor HTTP interceptors.*.model.tsfor main interfaces and types.*.util.tsfor pure helper functions.
For example, project-detail-page.component.ts immediately tells you it's a page. projects-api.service.ts tells you that service talks to the backend. projects-store.service.ts tells you that file manages the feature's state.
Performance and architecture
A good Angular architecture isn't only about tidy files. It also affects the application's performance.
Using lazy-loaded features means avoiding loading all the code at startup. If a user only visits the home page, there's no point in immediately downloading the code for the admin, the editor, the dashboard and every internal section as well.
Standalone components help because they make it easier to load components and routes granularly. Signals help because they make rendering more precise and state easier to track. The smart/dumb pattern helps because it reduces huge components and templates that are hard to optimise.
Some good practices:
- use lazy loading for features that aren't immediately needed;
- use
ChangeDetectionStrategy.OnPushin components; - use
@forwithtrackfor efficient lists; - keep components small and focused;
- avoid heavy logic directly in the template;
- use signals and computed for derived values;
- load images and assets in an optimised way;
- separate the admin from the public frontend whenever possible.
Common mistakes to avoid
1. Components that are too large
A component that holds a huge template, HTTP calls, form handling, state, validation, data mapping and navigation logic quickly becomes unmanageable. When a component takes on too many responsibilities, it's time to split it.
2. Global services for everything
Not every service needs to be providedIn: 'root'. If a service is needed by one feature only, providing it at route or component level may be more appropriate. This reduces unnecessary global state and makes the lifecycle more predictable.
3. A Shared folder that is too big
A huge shared folder is often a sign of weak architecture. Shared should contain truly generic components and utilities, not pieces of features put there for convenience.
4. Features coupled to each other
When a feature directly imports files from another feature, the project becomes fragile. It's better to extract the shared code into a common area with a clear responsibility.
5. Business logic in the template
The template must stay readable. If a condition becomes too complex, move it into a computed, a clear method or the feature's store.
6. Lack of conventions
If every developer creates folders and names their own way, the project loses consistency. Conventions must be simple, documented and followed.
Practical example: a well-structured Blog feature
Imagine a blog section with an article list, article detail, search, category filters and content management on the admin side. A tidy structure could be:
src/app/features/blog/
blog.routes.ts
pages/
blog-list-page.component.ts
blog-detail-page.component.ts
components/
blog-card.component.ts
blog-search.component.ts
blog-category-filter.component.ts
blog-empty-state.component.ts
data-access/
blog-api.service.ts
blog-store.service.ts
models/
blog-post.model.ts
blog-category.model.ts
utils/
reading-time.util.ts
slug.util.ts
The blog-list-page page is smart: it uses the store, loads data, and handles filters and search. The blog-card component is dumb: it receives an article and shows title, excerpt, image and date. The blog-api service talks to the backend. The store holds state, loading, errors and filtered articles.
This kind of separation lets you change the card layout without touching the data logic, or change an API endpoint without modifying presentational components.
Final checklist for a scalable Angular architecture
Before considering your Angular app's structure solid, you can use this checklist:
- Are the main features organised inside
features? - Does each feature have separate routes, pages, components, data-access and models?
- Is global code really limited to
core? - Does
sharedcontain only generic, reusable elements? - Do features avoid direct dependencies on each other?
- Do the main routes use lazy loading?
- Do standalone components clearly declare their dependencies?
- Is HTTP logic kept out of components?
- Is feature state handled by stores, facades or dedicated services?
- Are signals used for local state and derived values?
- Is RxJS used where asynchronous flows really need modelling?
- Do smart and dumb components have separate responsibilities?
- Are file names consistent?
- Are components small, readable and testable?
- Is there a convention shared by the team?
Conclusion
Organising an Angular project well doesn't mean creating a complicated structure. It means creating a structure that is clear, predictable and ready to grow. Feature-based architecture helps you think in terms of real features. Separating core and shared avoids confusion. Standalone components make dependencies more explicit. Signals simplify state management. The smart vs dumb components pattern improves readability, reuse and testability.
The most important thing is not to wait until the project is big before thinking about architecture. Decisions made at the beginning affect the application's entire lifecycle. A clean structure lets you add new pages, new features and new developers without turning the code into a maze.
If you are building a professional Angular app, start from a simple rule: everything must have a precise place. Features must hold the product logic, core must hold the global infrastructure, shared must hold truly reusable elements. From there, add standalone components, lazy loading, signals and a clear separation between smart and dumb components.
A scalable architecture isn't the most complex one, but the one the team can understand, maintain and evolve over time. Angular offers all the tools you need: it is up to us to use them with discipline, consistency and common sense.