Clean Architecture in Angular: A Scalable and Maintainable Structure
Clean Architecture, introduced by Robert C. Martin (Uncle Bob), defines a code organization model where business rules are isolated from implementation details. When applied to Angular, this architecture enables applications that stand the test of time, simplify testing, and allow frameworks, libraries, and infrastructure to be swapped without rewriting core business logic.
In this article, we will explore how to structure an Angular project following Clean Architecture principles, with separate layers for Domain, Application, Infrastructure, and Presentation. We will address the dependency rule, dependency inversion, and concrete patterns such as Use Cases, Repositories, and Mappers.
What You Will Learn
- The core principles of Clean Architecture
- How to organize folders in an Angular project
- The Use Case / Interactor pattern
- Repository Pattern for data access
- Mapping between Domain Models and DTOs
- Dependency Injection and dependency inversion
- Practical application of SOLID principles
Core Principles
Clean Architecture is built on one central concept: dependencies always point inward. Outer layers (UI, database, API) depend on inner layers (business logic, domain entities), never the other way around. This principle, known as the Dependency Rule, ensures that the core of the application remains independent of any specific technology.
The Four Layers
| Layer | Responsibility | Angular Examples |
|---|---|---|
| Domain | Entities, Value Objects, Repository interfaces | Pure TypeScript classes, no Angular dependencies |
| Application | Use Cases, business logic orchestration | Services coordinating domain operations |
| Infrastructure | Concrete implementations, data access, APIs | HttpClient, localStorage, Firebase, adapters |
| Presentation | UI, components, visual state management | Angular components, templates, pipes |
Folder Structure
The folder structure directly mirrors the architectural layers. Each application feature follows the same pattern, making the project predictable and navigable even for developers joining the team later.
src/
app/
core/ # Singleton services, guards, interceptors
interceptors/
guards/
features/
orders/ # Feature: Order Management
domain/
models/
order.model.ts # Domain entity
order-item.model.ts # Value Object
repositories/
order.repository.ts # Interface (port)
application/
use-cases/
create-order.use-case.ts
get-orders.use-case.ts
cancel-order.use-case.ts
dto/
create-order.dto.ts
order-response.dto.ts
mappers/
order.mapper.ts
infrastructure/
repositories/
order-http.repository.ts # HTTP implementation
order-mock.repository.ts # Mock implementation
adapters/
order-api.adapter.ts
presentation/
pages/
order-list/
order-detail/
components/
order-card/
order-form/
state/
orders.store.ts
shared/ # Shared components and utilities
ui/
pipes/
directives/
The Domain Layer
The Domain layer contains business entities and interfaces that define contracts for data access. This layer has zero dependencies on Angular, RxJS, or any external library. It consists of pure TypeScript classes and interfaces.
Domain Entities
Entities represent core business concepts. They contain validation rules and domain-intrinsic logic. They are not simple data containers (anemic models) but behavior-rich objects.
// domain/models/order.model.ts
export type OrderStatus = 'draft' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';
export class Order {
constructor(
public readonly id: string,
public readonly customerId: string,
private _items: OrderItem[],
private _status: OrderStatus = 'draft',
public readonly createdAt: Date = new Date()
) {
if (_items.length === 0) {
throw new Error('An order must contain at least one item');
}
}
get status(): OrderStatus {
return this._status;
}
get items(): ReadonlyArray<OrderItem> {
return [...this._items];
}
get total(): number {
return this._items.reduce(
(sum, item) => sum + item.subtotal, 0
);
}
confirm(): void {
if (this._status !== 'draft') {
throw new Error('Only draft orders can be confirmed');
}
this._status = 'confirmed';
}
cancel(): void {
if (this._status === 'shipped' || this._status === 'delivered') {
throw new Error('Cannot cancel an order that has already shipped');
}
this._status = 'cancelled';
}
addItem(item: OrderItem): void {
const existing = this._items.find(i => i.productId === item.productId);
if (existing) {
throw new Error('Product already exists. Update the quantity instead.');
}
this._items.push(item);
}
}
Value Objects
Value Objects are immutable objects defined by their value, not by an identity. Two Value Objects with the same attributes are considered equal. They are ideal for representing concepts like price, address, or quantity.
// domain/models/order-item.model.ts
export class OrderItem {
public readonly subtotal: number;
constructor(
public readonly productId: string,
public readonly productName: string,
public readonly quantity: number,
public readonly unitPrice: number
) {
if (quantity <= 0) {
throw new Error('Quantity must be greater than zero');
}
if (unitPrice < 0) {
throw new Error('Unit price cannot be negative');
}
this.subtotal = quantity * unitPrice;
}
equals(other: OrderItem): boolean {
return this.productId === other.productId
&& this.quantity === other.quantity
&& this.unitPrice === other.unitPrice;
}
}
Repository Interface (Port)
The repository interface defines the contract for data access. It resides in the Domain layer because it represents what the domain needs, without specifying how data is obtained. This is the essence of dependency inversion.
// domain/repositories/order.repository.ts
import { Observable } from 'rxjs';
import { Order } from '../models/order.model';
export abstract class OrderRepository {
abstract getAll(): Observable<Order[]>;
abstract getById(id: string): Observable<Order>;
abstract getByCustomer(customerId: string): Observable<Order[]>;
abstract save(order: Order): Observable<Order>;
abstract delete(id: string): Observable<void>;
}
Why an Abstract Class Instead of an Interface?
Angular's Dependency Injection system requires a token at runtime. TypeScript interfaces are erased during compilation and cannot be used as DI tokens. Abstract classes, however, exist at runtime and serve as both a contract and an injection token.
The Application Layer: Use Cases
The Application layer contains use cases (or interactors) that orchestrate business logic by coordinating domain entities and repositories. Each use case represents a single operation the application can perform. This layer depends on the Domain but has no knowledge of Infrastructure or Presentation details.
Use Case Structure
// application/use-cases/create-order.use-case.ts
import { Injectable } from '@angular/core';
import { Observable, map } from 'rxjs';
import { Order } from '../../domain/models/order.model';
import { OrderItem } from '../../domain/models/order-item.model';
import { OrderRepository } from '../../domain/repositories/order.repository';
import { CreateOrderDto } from '../dto/create-order.dto';
import { OrderResponseDto } from '../dto/order-response.dto';
import { OrderMapper } from '../mappers/order.mapper';
@Injectable({ providedIn: 'root' })
export class CreateOrderUseCase {
constructor(private readonly orderRepo: OrderRepository) {}
execute(dto: CreateOrderDto): Observable<OrderResponseDto> {
const items = dto.items.map(
i => new OrderItem(i.productId, i.productName, i.quantity, i.unitPrice)
);
const order = new Order(
crypto.randomUUID(),
dto.customerId,
items
);
return this.orderRepo.save(order).pipe(
map(saved => OrderMapper.toResponse(saved))
);
}
}
DTOs and Mappers
Data Transfer Objects (DTOs) define the shape of data entering and leaving the Application layer. Mappers convert between domain entities and DTOs, keeping the two worlds separate.
// application/dto/create-order.dto.ts
export interface CreateOrderDto {
customerId: string;
items: {
productId: string;
productName: string;
quantity: number;
unitPrice: number;
}[];
}
// application/dto/order-response.dto.ts
export interface OrderResponseDto {
id: string;
customerId: string;
status: string;
items: {
productId: string;
productName: string;
quantity: number;
unitPrice: number;
subtotal: number;
}[];
total: number;
createdAt: string;
}
// application/mappers/order.mapper.ts
import { Order } from '../../domain/models/order.model';
import { OrderResponseDto } from '../dto/order-response.dto';
export class OrderMapper {
static toResponse(order: Order): OrderResponseDto {
return {
id: order.id,
customerId: order.customerId,
status: order.status,
items: order.items.map(item => ({
productId: item.productId,
productName: item.productName,
quantity: item.quantity,
unitPrice: item.unitPrice,
subtotal: item.subtotal,
})),
total: order.total,
createdAt: order.createdAt.toISOString(),
};
}
}
The Infrastructure Layer
The Infrastructure layer provides concrete implementations of the interfaces defined in the Domain. This is where HTTP calls, localStorage persistence, Firebase integration, and any other technical detail reside. This layer depends on both the Domain (to implement interfaces) and external libraries (HttpClient, Firebase SDK).
// infrastructure/repositories/order-http.repository.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, map } from 'rxjs';
import { Order } from '../../domain/models/order.model';
import { OrderItem } from '../../domain/models/order-item.model';
import { OrderRepository } from '../../domain/repositories/order.repository';
interface OrderApiResponse {
id: string;
customer_id: string;
status: string;
items: {
product_id: string;
product_name: string;
qty: number;
price: number;
}[];
created_at: string;
}
@Injectable()
export class OrderHttpRepository extends OrderRepository {
private readonly baseUrl = '/api/orders';
constructor(private readonly http: HttpClient) {
super();
}
getAll(): Observable<Order[]> {
return this.http.get<OrderApiResponse[]>(this.baseUrl).pipe(
map(responses => responses.map(r => this.toDomain(r)))
);
}
getById(id: string): Observable<Order> {
return this.http.get<OrderApiResponse>(
`${this.baseUrl}/${id}`
).pipe(
map(r => this.toDomain(r))
);
}
getByCustomer(customerId: string): Observable<Order[]> {
return this.http.get<OrderApiResponse[]>(
`${this.baseUrl}?customer=${customerId}`
).pipe(
map(responses => responses.map(r => this.toDomain(r)))
);
}
save(order: Order): Observable<Order> {
const payload = this.toApi(order);
return this.http.post<OrderApiResponse>(this.baseUrl, payload).pipe(
map(r => this.toDomain(r))
);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
private toDomain(response: OrderApiResponse): Order {
const items = response.items.map(
i => new OrderItem(i.product_id, i.product_name, i.qty, i.price)
);
return new Order(
response.id,
response.customer_id,
items,
response.status as any,
new Date(response.created_at)
);
}
private toApi(order: Order): object {
return {
customer_id: order.customerId,
status: order.status,
items: order.items.map(i => ({
product_id: i.productId,
product_name: i.productName,
qty: i.quantity,
price: i.unitPrice,
})),
};
}
}
Dependency Injection Configuration
The binding between interface and implementation happens through Angular's DI system. In the provider configuration, we specify which concrete implementation should be used when the abstract repository class is requested.
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { OrderRepository } from './features/orders/domain/repositories/order.repository';
import { OrderHttpRepository } from './features/orders/infrastructure/repositories/order-http.repository';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
{
provide: OrderRepository,
useClass: OrderHttpRepository,
},
],
};
The Substitutability Advantage
By changing a single line of configuration, you can switch from OrderHttpRepository
to OrderMockRepository for testing, or to OrderFirebaseRepository
if you decide to migrate the backend. The rest of the application remains unchanged.
The Presentation Layer
The Presentation layer encompasses Angular components, templates, and visual state management. Components interact exclusively with Use Cases from the Application layer, with no knowledge of domain or infrastructure details.
// presentation/pages/order-list/order-list.component.ts
import { Component, OnInit, signal } from '@angular/core';
import { GetOrdersUseCase } from '../../../application/use-cases/get-orders.use-case';
import { OrderResponseDto } from '../../../application/dto/order-response.dto';
@Component({
selector: 'app-order-list',
standalone: true,
templateUrl: './order-list.component.html',
styleUrls: ['./order-list.component.css'],
})
export class OrderListComponent implements OnInit {
orders = signal<OrderResponseDto[]>([]);
loading = signal(true);
error = signal<string | null>(null);
constructor(private readonly getOrders: GetOrdersUseCase) {}
ngOnInit(): void {
this.getOrders.execute().subscribe({
next: (data) => {
this.orders.set(data);
this.loading.set(false);
},
error: (err) => {
this.error.set('Failed to load orders');
this.loading.set(false);
},
});
}
}
SOLID Principles in Practice
Clean Architecture is the natural vehicle for applying SOLID principles in an Angular project. Here is how each principle manifests in the structure we have defined.
| Principle | Application in Clean Architecture |
|---|---|
| Single Responsibility | Each Use Case has a single responsibility. Each layer has a well-defined role. |
| Open/Closed | New Repository implementations (e.g., Firebase) without modifying existing code. |
| Liskov Substitution | Any Repository implementation can replace the current one without side effects. |
| Interface Segregation | DTOs expose only the necessary data. Repository interfaces are feature-specific. |
| Dependency Inversion | High-level layers (Use Cases) depend on abstractions (abstract Repository), not concrete implementations. |
Testing in Clean Architecture
One of the main advantages of Clean Architecture is testability. Each layer can be tested in isolation thanks to dependency separation.
// application/use-cases/create-order.use-case.spec.ts
import { of } from 'rxjs';
import { CreateOrderUseCase } from './create-order.use-case';
import { OrderRepository } from '../../domain/repositories/order.repository';
import { Order } from '../../domain/models/order.model';
import { OrderItem } from '../../domain/models/order-item.model';
describe('CreateOrderUseCase', () => {
let useCase: CreateOrderUseCase;
let mockRepo: jasmine.SpyObj<OrderRepository>;
beforeEach(() => {
mockRepo = jasmine.createSpyObj('OrderRepository', ['save']);
useCase = new CreateOrderUseCase(mockRepo);
});
it('should create an order and return a response DTO', (done) => {
const dto = {
customerId: 'cust-1',
items: [{
productId: 'prod-1',
productName: 'Widget',
quantity: 2,
unitPrice: 25.00,
}],
};
const savedOrder = new Order(
'order-123', 'cust-1',
[new OrderItem('prod-1', 'Widget', 2, 25.00)]
);
mockRepo.save.and.returnValue(of(savedOrder));
useCase.execute(dto).subscribe(result => {
expect(result.id).toBe('order-123');
expect(result.total).toBe(50);
expect(result.items.length).toBe(1);
expect(mockRepo.save).toHaveBeenCalledTimes(1);
done();
});
});
});
Common Mistakes to Avoid
Adopting Clean Architecture requires discipline. Here are the most frequent mistakes that undermine the architecture's benefits.
| Mistake | Consequence | Solution |
|---|---|---|
| Using HttpClient directly in components | Coupling between UI and infrastructure | Always go through Use Cases and Repositories |
| Anemic domain entities (data only, no logic) | Business logic ends up in services, hard to test | Enrich entities with validations and behavior |
| DTOs that mirror entities 1:1 | Changing the domain directly impacts the API | Keep DTOs and models independent, use Mappers |
| Circular dependencies between layers | Dependency Rule violation, fragile code | Verify that dependencies always point toward the Domain |
| Over-engineering simple applications | Unjustified complexity, slower development | Assess project complexity before adopting Clean Architecture |
When to Adopt Clean Architecture
Clean Architecture is not the solution to every problem. Its adoption comes with a cost in terms of initial complexity and amount of code. Here is a practical guide for deciding when it makes sense.
Quick Assessment
| Scenario | Clean Architecture? | Alternative |
|---|---|---|
| Prototype / MVP | No | Simple feature-based structure |
| Simple CRUD application | Probably not | Layered architecture (MVC) |
| Enterprise app with complex logic | Yes | - |
| Large team, long maintenance expected | Yes | - |
| Possible backend or technology change | Yes | - |
Conclusion
Clean Architecture applied to Angular transforms how we structure frontend applications. By separating the domain from the technology, we achieve code that is more testable, maintainable, and resilient to change. The Dependency Rule ensures that the application's core remains stable even when we swap frameworks, libraries, or external services.
The initial cost in terms of boilerplate is offset by the ease of long-term maintenance, structural clarity, and the ability to have different teams work on independent layers. Like every architectural choice, it should be calibrated to the actual complexity of the project: for enterprise applications with significant business logic, Clean Architecture is an investment that pays off over time.
Related deep dive: 07 - Migrazione Step-by-Step: Da Monolith a Modular Monolith.
Related deep dive: 08 - Case Study: Migrazione da Microservices a Modular Monolith (ROI Quantificato).
Frequently asked questions
What is the main principle of Clean Architecture?
The main principle of Clean Architecture is that dependencies always point inward, meaning outer layers depend on inner layers, never the other way around.
What is the purpose of the Domain Layer in an Angular project following Clean Architecture principles?
The Domain Layer contains business entities and interfaces that define contracts for data access, with zero dependencies on Angular, RxJS, or any external library.
How does the Application Layer depend on other layers in a Clean Architecture structure?
The Application Layer depends on the Domain layer but has no knowledge of Infrastructure or Presentation details, allowing it to orchestrate business logic without being tied to specific technology.
What is one of the main advantages of using Clean Architecture in an Angular project?
One of the main advantages of Clean Architecture is testability, as each layer can be tested in isolation thanks to dependency separation.
Commenti
Caricamento commenti...
Sign in to comment