If you’re reading this, chances are that you are already familiar with the hexagonal architecture, the well-known design approach that helps decouple core business logic from external dependencies. But although it’s a very popular approach for backend development, you probably haven’t seen it applied as often in frontend development.
Introduction
I think it’s due to the nature of frameworks like Angular, where business logic tends to be tightly coupled with built-in mechanisms coupled with the technology, such as components, services, pipes, directives and validators. As a result, frontend frameworks often align more naturally with patterns like MVVM, MVC or MVP.
In recent years I had the opportunity to design the architecture of an Angular project. Coming from a backend-oriented background, I wanted to explore whether hexagonal architecture could fit for frontend development in a similar way that we had already successfully used it in many backend projects at WATA Factory. Initially, I was concerned that it might be an overkill or feel somewhat forced. However, over time, I found more benefits than drawbacks.
One of the main advantages was the structured organization of files, which provided a clear and consistent architecture. Despite the additional boilerplate and interfaces, this approach made the project more accessible to both frontend and backend developers. The familiarity of the structure facilitated onboarding and collaboration. As a result, this architectural style is now being adopted in other projects at WATA Factory, where it has proven to enhance scalability and testability.
Project Structure Overview
Our project is organized using a vertical slicing approach. This means that each feature is self-contained, including everything from business logic to presentation.
The primary folders include:
app/
│── core/ # Cross-cutting concerns (DI, external libs, etc.)
│── shared/ # Shared logic across features
│ |── domain/ # Shared business logic, domain models, validation
│ |── application/ # Shared application use cases
│ |── infrastructure/ # Shared infrastructure (APIs, repositories, utilities, adapters)
│ |── presentation/ # Shared presentation logic (UI components, directives, pipes)
│── authentication/ # Feature: Logic for Authentication (example)
│ |── domain/ # Feature: Business logic, domain models, validation
│ |── application/ # Feature: Application use cases
│ |── infrastructure/ # Feature: External integrations (APIs, DB, services)
│ |── presentation/ # Feature: UI components, directives, services, styles
│── other-feature/ # Another feature (follows the same structure)
|── domain/
|── application/
|── infrastructure/
|── presentation/
Core Folder (app/core
)
This folder contains cross-cutting concerns such as dependency injection configuration and external library setups (e.g., JWT, translation services).
Dependency Injection (app/core/di
)
Angular’s DI system works seamlessly with abstract classes, but not with interfaces. While I prefer using interfaces for strict contract enforcement without shared logic—allowing for complete decoupling of the implementation—there is a trade-off. Using interfaces requires additional boilerplate, specifically with the need to use InjectionToken
to integrate them into Angular’s DI system.
To configure this, we’ll use a file called injection.ts
, where we will establish default implementations using InjectionToken
as part of the DI container pattern.
export const AUTH_REPOSITORY_TOKEN = new InjectionToken<AuthRepository>(
'AuthRepository',
);
export const DI_PROVIDERS = [
{
provide: AUTH_REPOSITORY_TOKEN,
useClass: ApiAuthRepository,
},
// other providers
];
These providers are then added to app.config.ts
.
Shared Folder (app/shared
)
This folder will also be structured to house reusable logic across features while maintaining clear separation of concerns. Ensure that everything within the shared folder aligns with the appropriate domain, application, infrastructure, or presentation layer.
Feature Folders (app/authentication
)
Each feature follows a vertical slice, meaning it contains its own set of layers. Keep in mind that we will use Authentication just as an example of a feature. We follow the same vertical slicing strategy for other features as well, ensuring a consistent, maintainable, and scalable architecture throughout the project.
Domain Layer (app/authentication/domain
)
Contains the core business logic, domain models, and validation rules.
Domain Models (app/authentication/domain/models
)
Domain models encapsulate validation logic and domain-specific behavior:
export class UsernameModel {
readonly value: string;
constructor(value: string) {
if (!value) {
throw new EmptyUsernameError();
}
const usernameRegex = /^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$/;
if (!usernameRegex.test(value)) {
throw new InvalidUsernameError();
}
this.value = value;
}
}
Domain Exceptions (app/authentication/domain/errors
)
This directory contains domain-specific exceptions that represent business rule violations.
Domain Repository Interfaces (app/authentication/domain/repositories
)
This directory contains abstractions for data repositories, defining the contracts that repository implementations must follow.
export interface AuthRepository {
loginAttempt(loginModel: LoginModel): Observable<AuthTokenModel>;
}
Application Layer (app/authentication/application
)
This layer contains application services that orchestrate use cases. We also integrate here NGRX stores to manage state, though a deeper dive into NGRX would require a separate post about it.
Use Cases (app/authentication/application/use-cases
)
Use cases implement business logic and interact with repositories. They follow a consistent interface UseCase<S, T>
, where S
is the input model and T
is the output model:
export class LoginUseCase implements UseCase<LoginModel, UserStatusModel> {
constructor(private authRepository: AuthRepository) {
}
execute(loginModel: LoginModel): Observable<UserStatusModel> {
return this.authRepository.loginAttempt(loginModel);
}
}
The interface is defined in app/shared/application/use-cases/base/use-case.ts
import { Observable } from 'rxjs';
export interface UseCase<S, T> {
execute(params: S): Observable<T>;
}
Infrastructure Layer (app/authentication/infrastructure
)
Manages implementations for external integrations such as API calls, database interactions, and other external services.
Presentation Layer (app/authentication/presentation
)
This layer is tightly coupled with Angular and includes components, directives, services, and styles. Use cases are injected into components via interfaces to maintain separation from the application and infrastructure layers.
Conclusion
At WATA Factory, we’ve been using this architecture in our Angular projects, and it has brought us a ton of benefits. By structuring our code with hexagonal architecture and vertical slicing, we’ve made our applications more maintainable, scalable, and testable.
One of the biggest wins is that our business logic stays independent from Angular and other external dependencies. This makes our code modular, reusable, and much easier to adapt when requirements change.
If you’re looking for a way to keep your Angular codebase clean and future-proof, we highly recommend giving this approach a shot. It’s been a game-changer for us!