A practical exploration of architectural boundaries, dependency inversion, and the hidden cost of overengineering in modern Angular applications.
Clean Architecture is one of those architectural concepts that almost everyone agrees is valuable.
Until someone asks how many interfaces, abstractions, folders, and layers we actually need to implement it.
In backend development, separating business rules from infrastructure can make a lot of sense. But when we bring the same principles into Angular, things become more interesting.
Angular already provides components, services, dependency injection, routing, and a well-defined application structure.
So, do we really need Clean Architecture?
Or are we simply adding complexity to a framework that already solves many architectural problems?
Let's explore this question through a practical example. The code examples below are based on the companion Angular Clean Architecture Lab. Where the article shows a possible future HTTP integration, it is labeled as conceptual because the lab intentionally has no appointment API or HTTP repository.
1. The Problem: Angular Applications Don't Usually Start Complex
Most Angular applications start with something straightforward.
A component displays information, a service retrieves data, and everything works.
Here is a generic HTTP-backed service. It illustrates a common starting point; it is not the appointment service implemented in the lab:
import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import type { Appointment, AppointmentDraft } from './appointment'; @Injectable({ providedIn: 'root' }) export class AppointmentApiService { private readonly http = inject(HttpClient); createAppointment(data: AppointmentDraft) { return this.http.post<Appointment>('/api/appointments', data); } } There is nothing inherently wrong with this implementation.
It is simple, readable, and easy to understand.
But applications evolve. We may add business validation, multiple API endpoints, data transformations, complex workflows, permission checks, state management, and error handling.
The important question isn't whether a service is too large. It is whether unrelated responsibilities are becoming coupled in ways that make future changes difficult.
2. What Clean Architecture Actually Tries to Solve
Clean Architecture is not fundamentally about creating folders named domain, application, and infrastructure.
Its central principle is the Dependency Rule: source-code dependencies should point toward inner policy. The Domain should not depend on Angular, HTTP clients, browser APIs, or persistence implementations. Application coordinates operations using inward-facing contracts.
A simplified view of this lab is:
Presentation -> Application -> Domain Infrastructure -> Domain contract Angular composition root -> Application and Infrastructure The lab defines AppointmentRepository in Domain, so the Infrastructure adapter depends on that Domain contract. Other Clean Architecture designs can put a port in Application when its ownership belongs there; the important point is that the inner policy does not import an outer implementation.
The goal is to isolate business rules from implementation details. Changing a storage adapter should not require rewriting appointment rules.
Applying this principle does not mean every Angular application needs four layers. Architecture should solve an existing or reasonably anticipated problem, not introduce abstractions simply because they exist in a diagram.
3. Angular Already Provides Some Architectural Foundations
Before introducing additional layers, we should acknowledge what Angular already offers:
- Dependency Injection.
- Standalone components.
- Services.
- Routing and lazy loading.
- Hierarchical providers.
- Component lifecycle management.
These are valuable architectural tools. Angular Dependency Injection lets the composition root supply replaceable implementations. Standalone components make template dependencies explicit through their imports.
We do not need to recreate Angular's capabilities. Instead, we can define boundaries for concerns Angular does not decide for us: business rules, application operations, and domain-specific behavior.
4. A Practical Example: Vehicle Appointments
The lab manages vehicle appointments for a logistics operation. Its Domain currently enforces these rules:
- A vehicle ID is required and is trimmed when the appointment is created.
- The scheduled date must parse and cannot be in the past.
- An active appointment for the same normalized vehicle and timestamp is rejected.
- A cancelled appointment cannot be cancelled again.
- Cancellation preserves the appointment record and changes its status.
The comparison page contains a conventional Angular service example. It uses the same Domain policies and repository adapter, but the service coordinates them directly rather than going through application use cases:
import { inject, Injectable } from '@angular/core'; import { isDuplicate, validateDraft, type AppointmentDraft, } from '../appointments/domain/appointment'; import { APPOINTMENT_REPOSITORY } from '../appointments/infrastructure/providers/appointment.providers'; @Injectable({ providedIn: 'root' }) export class SimpleSchedulingService { private readonly repository = inject(APPOINTMENT_REPOSITORY); async create(draft: AppointmentDraft): Promise<string> { const errors = validateDraft(draft); if (errors.length) return 'errors.validation'; const appointments = await this.repository.findAll(); if (isDuplicate(appointments, draft)) return 'errors.duplicate'; await this.repository.create(draft); return 'comparison.simpleSuccess'; } } The returned strings are Transloco keys; localization belongs at the presentation edge. This service deliberately imports Domain policies and an Infrastructure token. It demonstrates a compact Angular approach, not the Clean Architecture dependency rule.
In a feature that uses a real backend, this simple service might call HttpClient directly. The lab does not include that backend, so its running comparison uses browser storage instead of pretending an API exists.
5. Introducing Clean Architecture
The Clean Architecture path in the lab separates the appointment operation from its storage implementation.
Step 1: Define the Domain
The actual appointment model uses ISO date strings, includes status and creation time, and represents a draft as a TypeScript interface:
export type AppointmentStatus = 'scheduled' | 'cancelled'; export interface AppointmentDraft { readonly vehicleId: string; readonly scheduledDate: string; } export interface Appointment extends AppointmentDraft { readonly id: string; readonly status: AppointmentStatus; readonly createdAt: string; } Validation returns typed policy errors without localized text:
export type AppointmentValidationError = | { readonly type: 'vehicle-required'; readonly field: 'vehicleId' } | { readonly type: 'date-invalid'; readonly field: 'scheduledDate' } | { readonly type: 'date-in-past'; readonly field: 'scheduledDate' } | { readonly type: 'already-cancelled' }; export function validateDraft( draft: AppointmentDraft, now = new Date(), ): AppointmentValidationError[] { const errors: AppointmentValidationError[] = []; if (!draft.vehicleId.trim()) errors.push({ type: 'vehicle-required', field: 'vehicleId' }); const date = new Date(draft.scheduledDate); if (!draft.scheduledDate || Number.isNaN(date.getTime())) { errors.push({ type: 'date-invalid', field: 'scheduledDate' }); } else if (date.getTime() < now.getTime()) { errors.push({ type: 'date-in-past', field: 'scheduledDate' }); } return errors; } The Domain also owns duplicate detection and the cancellation transition. It imports TypeScript only. Transloco messages are selected by Presentation, keeping localization out of business policy.
Step 2: Define the Repository Contract
In this project the repository contract lives in Domain because it is expressed in terms of Domain appointments and drafts:
import type { Appointment, AppointmentDraft } from './appointment'; export interface AppointmentRepository { findAll(): Promise<Appointment[]>; findById(id: string): Promise<Appointment | null>; create(draft: AppointmentDraft): Promise<Appointment>; cancel(id: string): Promise<Appointment>; } The port exposes only operations required by the use cases. There is no generic update operation that could edit a cancelled appointment.
Step 3: Represent Expected Failures Explicitly
The lab uses a discriminated Result union:
export type Result<T, E> = { readonly success: true; readonly value: T } | { readonly success: false; readonly error: E }; Application errors distinguish validation, duplicate, not-found, already-cancelled, and expected infrastructure failures:
import type { AppointmentValidationError } from '../domain/appointment'; export type AppointmentError = | { readonly type: 'validation-failure'; readonly errors: AppointmentValidationError[] } | { readonly type: 'appointment-not-found'; readonly id: string } | { readonly type: 'duplicate-appointment' } | { readonly type: 'appointment-already-cancelled' } | { readonly type: 'infrastructure-failure'; readonly message: string }; Expected business failures are part of the use-case contract. Unexpected exceptions are not silently converted into business results. A Result type is one option for making expected outcomes explicit; Clean Architecture does not require this particular error style.
Step 4: Implement the Use Case
The following excerpt follows CreateAppointmentUseCase in the lab. It validates, checks duplicates, normalizes the draft, and translates only the adapter's known InfrastructureFailure. Other exceptions propagate:
async execute(draft: AppointmentDraft): Promise<Result<Appointment, AppointmentError>> { const errors = validateDraft(draft, this.clock()); if (errors.length) return err({ type: 'validation-failure', errors }); try { if (isDuplicate(await this.repository.findAll(), draft)) { return err({ type: 'duplicate-appointment' }); } return ok( await this.repository.create({ vehicleId: draft.vehicleId.trim(), scheduledDate: new Date(draft.scheduledDate).toISOString(), }), ); } catch (cause) { if (cause instanceof InfrastructureFailure) { return err({ type: 'infrastructure-failure', message: cause.message }); } throw cause; } } The use case does not know about Angular, HttpClient, or a storage key. Cancellation and read operations have their own use cases.
6. Implementing the Infrastructure Adapter
The actual adapter is InMemoryAppointmentRepository. Despite its name, it persists appointment JSON in browser localStorage and keeps an in-memory cache. It validates stored records and returns copies so callers do not mutate its stored objects.
The project has no appointment API, HTTP repository, API DTO, or mapper. HttpClient is configured for Transloco's same-origin language catalog files, not appointment persistence.
If an API is added later, an HTTP adapter can implement AppointmentRepository. Introduce API DTOs and mapping when the wire representation differs from the Domain model. This is an architectural extension point, not a currently demonstrated adapter replacement.
7. Connecting Dependencies with Angular DI
TypeScript interfaces do not exist at runtime, so Angular cannot inject AppointmentRepository directly. The project declares an InjectionToken:
import { InjectionToken } from '@angular/core'; export const APPOINTMENT_REPOSITORY = new InjectionToken<AppointmentRepository>( 'APPOINTMENT_REPOSITORY', ); Infrastructure provides its adapter under that token:
import { InMemoryAppointmentRepository } from '../repositories/in-memory-appointment.repository'; import type { AppointmentRepository } from '../../domain/appointment.repository'; export const appointmentProviders = [ InMemoryAppointmentRepository, { provide: APPOINTMENT_REPOSITORY, useExisting: InMemoryAppointmentRepository }, ]; The Angular composition root creates the use case from the token. This is the actual provider pattern used by the project:
{ provide: CREATE_APPOINTMENT, useFactory: (repository: ConstructorParameters<typeof CreateAppointmentUseCase>[0]) => new CreateAppointmentUseCase(repository), deps: [APPOINTMENT_REPOSITORY], } The facade injects the use-case token, while the use case remains a plain TypeScript class. The repository can also be replaced in tests through DI. A production HTTP implementation is possible, but it has not been added to this no-backend lab.
8. Testing the Use Case Without Angular
The application tests instantiate use cases directly and provide a FakeAppointmentRepository. The fake implements all four operations required by the real contract; there is no Angular TestBed in Domain or Application tests.
For example, the fake's create operation constructs the actual Domain shape:
async create(draft: AppointmentDraft): Promise<Appointment> { const item: Appointment = { ...draft, id: 'id-' + (this.records.length + 1), status: 'scheduled', createdAt: new Date().toISOString(), }; this.records.push(item); return item; } A test uses a relative future date so it remains valid when run later:
const draft: AppointmentDraft = { vehicleId: 'TRK-9', scheduledDate: new Date(Date.now() + 86_400_000).toISOString(), }; const result = await new CreateAppointmentUseCase(repository).execute(draft); expect(result.success).toBe(true); expect(repository.records).toHaveLength(1); The full suite also tests invalid and duplicate creation, missing records, repeated cancellation, infrastructure failures, and unexpected exceptions. Infrastructure tests cover browser storage; Presentation tests use Angular TestBed. The layers use different test techniques because they have different responsibilities.
9. Comparing the Two Approaches
| Aspect | Conventional Angular service | Clean Architecture path |
|---|---|---|
| Initial implementation | Fewer files and direct orchestration | More setup and indirection |
| Business rules | Shared Domain functions called by the service | Domain rules coordinated by an application use case |
| Persistence dependency | Service injects the repository token directly | Use case depends on the Domain repository contract |
| Unit testing business flow | Possible, but the service has Angular and adapter dependencies | Use case runs with a fake repository and no TestBed |
| HTTP replacement | Not demonstrated by this lab | A compatible adapter could be added behind the repository contract; no HTTP adapter exists today |
| Navigation through code | Direct | More layers and tokens to navigate |
| Good fit | Small, stable flows | Workflows or policies likely to evolve |
The comparison is intentionally bounded: both paths use the same Domain policies and local storage, but the Clean path isolates orchestration behind a use case. It does not demonstrate an HTTP adapter swap. Neither approach is inherently superior; the appropriate trade-off depends on change and business-rule complexity.
10. The Hidden Cost of Clean Architecture
For this small workflow, the Clean path adds four use cases, repository and use-case tokens, provider wiring, a facade, and more files. These are real navigation and maintenance costs.
Boilerplate is not free
Every abstraction has a maintenance cost. Developers need to understand where behavior lives, how dependencies are wired, and which layer owns an operation.
Interfaces are not automatically valuable
An interface is useful when it defines a meaningful boundary. It is less useful when it only duplicates a stable implementation with no replacement or testing value. Dependency inversion is about controlling dependencies, not maximizing interfaces.
Abstraction can hide simple behavior
Excessive indirection can make an API request harder to trace. The goal is to make important behavior easier to change and understand, not harder to find.
11. When Is Clean Architecture Worth It?
There is no universal answer. These are heuristics, not rules:
| Situation | Possible choice |
|---|---|
| Small CRUD application with little policy | Keep the service focused and simple |
| Prototype or short-lived project | Avoid abstractions without a concrete need |
| Complex business workflows | Consider application use cases |
| Domain with significant business rules | Isolate the domain policy |
| Multiple entry points must share an operation | Reusable application logic may help |
| Infrastructure changes are likely | A repository boundary may reduce coupling |
A small application can have complex business rules, and a large application can still be overengineered. The relevant measure is the cost of changing and understanding behavior, not the number of files.
12. A More Pragmatic Approach: Start with Boundaries, Not Layers
Instead of starting with a predefined folder structure, identify responsibilities that need separation. A complex feature might benefit from Domain, Application, Infrastructure, and Presentation folders. A small notification feature might need only a service and component.
Do not add all four folders to every feature automatically. A straightforward service with clear responsibilities can be easier to maintain than an elaborate structure that does not solve a real problem.
13. Final Thoughts
Clean Architecture isn't inherently too complex for Angular. The problem is applying patterns without understanding what they are meant to solve.
Angular provides many tools for maintainable applications. Clean Architecture can complement them by isolating business rules and controlling dependency direction, but every boundary adds complexity.
Don't adopt Clean Architecture because your project is large. Adopt its principles when your business complexity justifies them.
Start with the simplest architecture that solves the current problem. Introduce stronger boundaries when the cost of coupling becomes real. The best architecture is the one that makes the application easier to understand, change, and maintain.
What do you think?
Do you use Clean Architecture in your Angular applications? Have you found that it improves maintainability, or does it introduce more complexity than necessary?
I'd love to hear how other developers approach this trade-off.



