What is Clean Architecture?
Clean Architecture is a software design philosophy that separates concerns into layers, with dependencies pointing inward toward the domain. The core principle is that business logic should not depend on frameworks, UI, or external services .
In BarberApp, we implement Clean Architecture with three main layers:
Domain Layer (core) - Business entities and rules
Adapters Layer (services) - External service implementations
Use Cases Layer (features) - Application-specific logic
Layer 1: Domain Layer (Core)
The domain layer is the heart of the application . It contains pure business logic independent of any framework or technology.
Location
src/app/core/
├── interfaces/ # Repository contracts
├── models/ # Domain entities
├── enums/ # Business enumerations
├── guards/ # Route guards
├── validators/ # Custom validators
├── constants/ # Business constants
├── utils/ # Pure utility functions
└── config/ # Application configuration
Repository Interfaces
Interfaces define contracts for data access without specifying implementation details.
core/interfaces/auth.repository.ts
core/interfaces/user.repository.ts
core/interfaces/appointment.repository.ts
import { UserBase } from "../models" ;
export interface AuthRepository {
register ( email : string , password : string ) : Promise < void | string >;
login ( email : string , password : string ) : Promise < void >;
logout () : Promise < void >;
isAuthenticated () : Promise < boolean >;
getCurrentUser () : Promise < UserBase | null >;
}
Notice that interfaces only define what operations are available, not how they’re implemented. This allows different implementations (Firebase, REST API, mock data) without changing the contract.
Injection Tokens Pattern
BarberApp uses Angular InjectionTokens to implement the Dependency Inversion Principle:
core/interfaces/auth.repository.token.ts
import { InjectionToken } from '@angular/core' ;
import { AuthRepository } from './auth.repository' ;
export const AUTH_REPOSITORY = new InjectionToken < AuthRepository >(
'AUTH_REPOSITORY'
);
Why use InjectionTokens instead of direct class injection?
Using InjectionToken provides several benefits:
Dependency Inversion : The domain layer defines the token, not the implementation
Multiple Implementations : Easy to provide different implementations for testing, development, or production
Framework Independence : The interface lives in core/ with no Firebase dependencies
Testability : Mock implementations can be injected without modifying code
// Without InjectionToken (tight coupling)
private authService = inject ( FirebaseAuthService ); // ❌ Depends on Firebase
// With InjectionToken (dependency inversion)
private authService = inject ( AUTH_REPOSITORY ); // ✅ Depends on interface
Domain Models
Models represent business entities with their properties and types:
core/models/user-base.model.ts
import { UserStatus , UserRoles , Sex } from '../enums' ;
export interface UserBase {
id : string ;
firstName : string ;
lastName : string ;
dni : string ;
sex : Sex ;
birthDate : Date ;
email : string ;
password ?: string ;
phone ?: string ;
profilePictureUrl : string ;
registrationDate : Date ;
role : UserRoles ;
status : UserStatus ;
}
UserBase Base user properties shared across all roles
Client Extends UserBase with client-specific fields
Specialist Extends UserBase with specialist-specific fields
Enums
Enums define domain-specific constants :
core/enums/user-roles.enum.ts
core/enums/user-status.enum.ts
core/enums/appointment-status.enum.ts
export enum UserRoles {
CLIENT = 'client' ,
SPECIALIST = 'specialist' ,
ADMIN = 'admin' ,
}
Guards
Guards implement access control logic :
core/guards/auth.guard.ts
core/guards/public.guard.ts
import { inject } from '@angular/core' ;
import { Router } from '@angular/router' ;
import { AuthFacade } from '../../features/auth/auth.facade' ;
export const authGuard = async () => {
const authFacade = inject ( AuthFacade );
const router = inject ( Router );
// Wait for auth check to complete
while ( authFacade . isCheckingAuth ()) {
await new Promise (( resolve ) => setTimeout ( resolve , 10 ));
}
const isAuthenticated = authFacade . isAuthenticated ();
if ( isAuthenticated ) {
return true ;
} else {
router . navigate ([ '/auth/login' ]);
return false ;
}
};
BarberApp uses functional guards (Angular 20+ pattern) instead of class-based guards. This is more lightweight and aligns with modern Angular practices.
Layer 2: Adapters Layer (Services)
The adapters layer implements the repository interfaces using specific technologies like Firebase and Cloudinary.
Location
src/app/services/
├── firebase/
│ ├── firebase-auth.service.ts
│ ├── firebase-user.service.ts
│ ├── firebase-specialty.service.ts
│ └── firebase-appointment.service.ts
└── cloudinary/
└── cloudinary.service.ts
Firebase Implementation Example
services/firebase/firebase-auth.service.ts
import { Injectable , inject } from '@angular/core' ;
import {
Auth ,
createUserWithEmailAndPassword ,
signInWithEmailAndPassword ,
signOut ,
onAuthStateChanged ,
User as FirebaseUser ,
} from '@angular/fire/auth' ;
import { AuthRepository } from '../../core/interfaces/auth.repository' ;
import { UserBase } from '../../core/models' ;
import { FirebaseUserService } from './firebase-user.service' ;
@ Injectable ({
providedIn: 'root' ,
})
export class FirebaseAuthService implements AuthRepository {
private auth : Auth = inject ( Auth );
private userService = inject ( FirebaseUserService );
async register ( email : string , password : string ) : Promise < string > {
const userCredential = await createUserWithEmailAndPassword (
this . auth ,
email ,
password
);
return userCredential . user . uid ;
}
async login ( email : string , password : string ) : Promise < void > {
await signInWithEmailAndPassword ( this . auth , email , password );
}
async logout () : Promise < void > {
await signOut ( this . auth );
}
async isAuthenticated () : Promise < boolean > {
return new Promise (( resolve ) => {
onAuthStateChanged ( this . auth , ( user : FirebaseUser | null ) => {
resolve ( !! user );
});
});
}
async getCurrentUser () : Promise < UserBase | null > {
return new Promise (( resolve ) => {
onAuthStateChanged ( this . auth , async ( user : FirebaseUser | null ) => {
if ( ! user ) return resolve ( null );
// Get full user data from Firestore
const userData = await this . userService . getUserByUId ( user . uid );
resolve ( userData );
});
});
}
}
The FirebaseAuthService implements the AuthRepository interface. This means:
It must provide all methods defined in the interface
It can add Firebase-specific logic internally
It can be swapped with another implementation (e.g., SupabaseAuthService) without changing consumers
Registering Implementations
Implementations are registered in app.config.ts:
import { AUTH_REPOSITORY } from './core/interfaces/auth.repository.token' ;
import { USER_REPOSITORY } from './core/interfaces/user.repository.token' ;
import { SPECIALTY_REPOSITORY } from './core/interfaces/specialty.repository.token' ;
import { APPOINTMENT_REPOSITORY } from './core/interfaces/appointment.repository.token' ;
import { FirebaseAuthService } from './services/firebase/firebase-auth.service' ;
import { FirebaseUserService } from './services/firebase/firebase-user.service' ;
import { FirebaseSpecialtyService } from './services/firebase/firebase-specialty.service' ;
import { FirebaseAppointmentService } from './services/firebase/firebase-appointment.service' ;
export const appConfig : ApplicationConfig = {
providers: [
// Repository implementations
{ provide: AUTH_REPOSITORY , useClass: FirebaseAuthService },
{ provide: USER_REPOSITORY , useClass: FirebaseUserService },
{ provide: SPECIALTY_REPOSITORY , useClass: FirebaseSpecialtyService },
{ provide: APPOINTMENT_REPOSITORY , useClass: FirebaseAppointmentService },
// ... other providers
]
};
Important : All repository implementations must be registered in app.config.ts. Forgetting to register a provider will result in runtime injection errors.
Layer 3: Use Cases Layer (Features)
The use cases layer contains application-specific business logic , state management, and orchestration between repositories.
Location
src/app/features/
├── auth/
│ ├── auth.facade.ts # Authentication business logic
│ ├── auth.routes.ts # Feature routes
│ ├── pages/ # Route components
│ ├── components/ # Feature-specific components
│ └── services/ # Feature-specific services (if needed)
├── dashboard/
├── appointments/
├── clients/
└── ...
Facades with Signals
Facades orchestrate business logic and manage state using Angular Signals :
features/auth/auth.facade.ts
import { Injectable , computed , inject , signal } from '@angular/core' ;
import { Router } from '@angular/router' ;
import { AUTH_REPOSITORY } from '../../core/interfaces/auth.repository.token' ;
import { UserBase } from '../../core/models' ;
import { UserRoles } from '../../core/enums' ;
@ Injectable ({
providedIn: 'root' ,
})
export class AuthFacade {
// Inject repository using InjectionToken
private authService = inject ( AUTH_REPOSITORY );
private router = inject ( Router );
// Reactive state with signals
private _user = signal < UserBase | null >( null );
private _loading = signal < boolean >( false );
private _error = signal < string | null >( null );
// Public readonly signals
readonly user = this . _user . asReadonly ();
readonly isAuthenticated = computed (() => !! this . _user ());
readonly isLoading = this . _loading . asReadonly ();
readonly error = this . _error . asReadonly ();
async login ( email : string , password : string ) : Promise < void > {
this . _loading . set ( true );
this . _error . set ( null );
try {
await this . authService . login ( email , password );
const user = await this . authService . getCurrentUser ();
this . _user . set ( user );
// Business rule: redirect based on role
if ( user ) {
this . redirectUserByRole ( user );
}
} catch ( error : any ) {
const errorMessage = this . getLoginErrorMessage ( error );
this . _error . set ( errorMessage );
this . _user . set ( null );
} finally {
this . _loading . set ( false );
}
}
redirectUserByRole ( user : UserBase ) : void {
switch ( user . role ) {
case UserRoles . CLIENT :
this . router . navigate ([ '/dashboard/client' ]);
break ;
case UserRoles . SPECIALIST :
this . router . navigate ([ '/dashboard/specialist' ]);
break ;
case UserRoles . ADMIN :
this . router . navigate ([ '/dashboard/admin' ]);
break ;
}
}
private getLoginErrorMessage ( error : any ) : string {
const code = error ?. code ;
switch ( code ) {
case 'auth/invalid-credential' :
return 'Correo o contraseña incorrectos.' ;
case 'auth/too-many-requests' :
return 'Demasiados intentos fallidos.' ;
default :
return 'Error al iniciar sesión.' ;
}
}
}
Why use Facades instead of Services?
Facades provide several advantages:
State Management : Centralized state using Angular Signals
Business Logic : Role-based routing, error handling, validation
Multiple Repository Orchestration : Can coordinate between multiple repositories
Simplified API : Components get a clean, simple API for complex operations
// Component usage
export class LoginComponent {
authFacade = inject ( AuthFacade );
// Simple, reactive state access
isLoading = this . authFacade . isLoading ;
error = this . authFacade . error ;
// Business logic encapsulated in facade
async onSubmit () {
await this . authFacade . login ( email , password );
// Facade handles: validation, API calls, state updates, navigation
}
}
Feature Organization
Each feature follows a consistent structure:
features/auth/
├── auth.facade.ts # State management & business logic
├── auth.routes.ts # Lazy-loaded routes
├── pages/
│ ├── login-page/
│ │ ├── login-page.component.ts
│ │ ├── login-page.component.html
│ │ └── login-page.component.css
│ └── register-page/
│ ├── register-page.component.ts
│ ├── register-page.component.html
│ └── register-page.component.css
├── components/
│ ├── login-form/
│ ├── register-form/
│ └── input-custom/
└── services/ # Feature-specific services (if needed)
Features are lazy-loaded using Angular’s route configuration, improving initial load performance.
Benefits of This Architecture
Easy Testing Mock repository implementations for unit testing facades and components
Swappable Backends Replace Firebase with Supabase, REST API, or GraphQL without changing business logic
Clear Dependencies Dependencies flow inward: Features → Services → Core (never the reverse)
Team Scalability Teams can work on features independently without conflicts
Example: Switching from Firebase to Supabase
Create Supabase implementation
services/supabase/supabase-auth.service.ts
@ Injectable ({ providedIn: 'root' })
export class SupabaseAuthService implements AuthRepository {
// Implement using Supabase SDK
}
Update app.config.ts
// Change ONE line:
{ provide : AUTH_REPOSITORY , useClass : SupabaseAuthService }
Done!
No changes needed in:
Domain layer (core/)
Facades (features/*/facades)
Components
Guards
This demonstrates the power of dependency inversion: change implementation without touching business logic .
Next Steps
Folder Structure Explore the complete directory structure and file naming conventions