Skip to main content

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:
  1. Domain Layer (core) - Business entities and rules
  2. Adapters Layer (services) - External service implementations
  3. 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.
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'
);
Using InjectionToken provides several benefits:
  1. Dependency Inversion: The domain layer defines the token, not the implementation
  2. Multiple Implementations: Easy to provide different implementations for testing, development, or production
  3. Framework Independence: The interface lives in core/ with no Firebase dependencies
  4. 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:
export enum UserRoles {
  CLIENT = 'client',
  SPECIALIST = 'specialist',
  ADMIN = 'admin',
}

Guards

Guards implement access control logic:
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

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:
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.';
    }
  }
}
Facades provide several advantages:
  1. State Management: Centralized state using Angular Signals
  2. Business Logic: Role-based routing, error handling, validation
  3. Multiple Repository Orchestration: Can coordinate between multiple repositories
  4. 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

1

Create Supabase implementation

services/supabase/supabase-auth.service.ts
@Injectable({ providedIn: 'root' })
export class SupabaseAuthService implements AuthRepository {
  // Implement using Supabase SDK
}
2

Update app.config.ts

// Change ONE line:
{ provide: AUTH_REPOSITORY, useClass: SupabaseAuthService }
3

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

Build docs developers (and LLMs) love