Saltar a contenido

Capítulo 8 - Parte 1: ¿Qué es un servicio? Responsabilidad única y separación de capas

Parte 1 de 4 · Capítulo 8 · PARTE V - Servicios e Inyección de Dependencias

Cuando una aplicación Angular crece más allá de los primeros componentes, emerge un problema silencioso: la lógica empieza a acumularse dentro de los componentes. Un componente que muestra un listado de productos termina también llamando a la API, transformando los datos, calculando totales y guardando el estado del carrito. El componente deja de ser una unidad de presentación y se convierte en un monolito que hace todo. Esta acumulación tiene nombre: violación del principio de responsabilidad única.

Los servicios existen para resolver exactamente ese problema. Un servicio en Angular es una clase de TypeScript cuyo propósito es encapsular lógica que no pertenece a la capa de presentación: acceso a datos, cálculos compartidos, reglas de negocio, comunicación entre partes no relacionadas de la aplicación. Al mover esa lógica fuera del componente, el componente recupera su responsabilidad real: coordinar la vista y reaccionar a las interacciones del usuario.

El principio de responsabilidad única aplicado a Angular

El principio de responsabilidad única (SRP, de sus siglas en inglés) establece que una clase debe tener una sola razón para cambiar. Un componente debería cambiar solo cuando cambia la forma en que se presenta la información al usuario. Si también cambia cuando cambia la URL de la API, o cuando el algoritmo de cálculo de descuentos se modifica, entonces ese componente tiene demasiadas responsabilidades.

La separación correcta en Angular se organiza en tres capas bien definidas. La capa de presentación la ocupan los componentes: reciben datos, los muestran y emiten eventos. La capa de lógica la ocupan los servicios: centralizan reglas de negocio y coordinan operaciones. La capa de datos también puede estar en servicios especializados (a veces llamados repositorios o gateways) que abstraen cómo se obtienen y persisten los datos, sin importar si vienen de una API REST, de localStorage o de una base de datos en memoria. Esta separación no es dogma: es pragmatismo que hace el código más testeable y más fácil de mantener.

El componente que hace demasiado

Veamos un componente que viola el principio de responsabilidad única. Todo lo que está mal en él es intencional: es el punto de partida que los servicios van a resolver.

import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { CurrencyPipe, NgFor, NgIf } from '@angular/common';

interface Producto {
  id: number;
  nombre: string;
  precio: number;
  stock: number;
}

@Component({
  selector: 'app-catalogo',
  standalone: true,
  imports: [CurrencyPipe, NgFor, NgIf],
  template: `
    <div *ngIf="cargando">Cargando...</div>
    <ul *ngIf="!cargando">
      <li *ngFor="let p of productosConDescuento">
        {{ p.nombre }} - {{ p.precioFinal | currency:'USD' }}
      </li>
    </ul>
  `
})
export class CatalogoComponent implements OnInit {
  cargando = true;
  // Mezcla de estado de UI, lógica de negocio y acceso a datos - todo junto
  productosConDescuento: Array<Producto & { precioFinal: number }> = [];

  constructor(private http: HttpClient) {}

  ngOnInit(): void {
    // Llamada HTTP directamente en el componente
    this.http.get<Producto[]>('https://api.ejemplo.com/productos').subscribe(productos => {
      // Lógica de negocio (descuento) mezclada con la carga
      this.productosConDescuento = productos
        .filter(p => p.stock > 0)
        .map(p => ({ ...p, precioFinal: p.precio * 0.9 })); // 10 % de descuento
      this.cargando = false;
    });
  }
}

Este componente tiene tres razones para cambiar: si la URL de la API cambia, si la lógica de descuento cambia, y si el diseño de la lista cambia. Ninguna de esas razones es la misma, lo que hace que el componente sea frágil y difícil de probar de forma aislada.

El componente que delega correctamente

El mismo componente, refactorizado para delegar la lógica a un servicio, quedaría así:

import { Component, OnInit, inject } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { ProductosService } from '../core/services/productos.service';

interface ProductoConPrecio {
  nombre: string;
  precioFinal: number;
}

@Component({
  selector: 'app-catalogo',
  standalone: true,
  imports: [CurrencyPipe],
  template: `
    @if (cargando) {
      <div>Cargando...</div>
    } @else {
      <ul>
        @for (producto of productos; track producto.nombre) {
          <li>{{ producto.nombre }} - {{ producto.precioFinal | currency:'USD' }}</li>
        }
      </ul>
    }
  `
})
export class CatalogoComponent implements OnInit {
  // El componente solo sabe mostrar datos y gestionar estado de UI
  private productosService = inject(ProductosService);
  cargando = true;
  productos: ProductoConPrecio[] = [];

  ngOnInit(): void {
    this.productosService.obtenerProductosConDescuento().subscribe(lista => {
      this.productos = lista;
      this.cargando = false;
    });
  }
}

Ahora el componente tiene una sola razón para cambiar: si cambia cómo se presenta la información. La lógica de la API y el cálculo del descuento viven en ProductosService, que tiene su propia razón para cambiar independiente del componente.

Arquitectura en capas de una aplicación Angular

El siguiente diagrama muestra cómo se relacionan las tres capas en una aplicación Angular típica. Las flechas indican la dirección de la dependencia: la capa de presentación depende de la de lógica, y esta depende de la de datos. Nunca al revés.

flowchart TD
    subgraph Presentación["Capa de Presentación"]
        C1[CatalogoComponent]
        C2[CarritoComponent]
        C3[DetalleProdComponent]
    end

    subgraph Lógica["Capa de Lógica - Servicios"]
        S1[ProductosService]
        S2[CarritoService]
        S3[DescuentosService]
    end

    subgraph Datos["Capa de Datos"]
        D1[ApiProductosService]
        D2[LocalStorageService]
    end

    C1 --> S1
    C2 --> S2
    C3 --> S1
    S1 --> D1
    S1 --> S3
    S2 --> D2

Esta separación no es solo estética. Tiene consecuencias prácticas: ProductosService puede ser probado unitariamente sin necesidad de renderizar ningún componente. ApiProductosService puede ser reemplazado por una implementación en memoria durante las pruebas. Y si mañana la lógica de descuentos cambia, solo se modifica DescuentosService, no todos los componentes que muestran precios.

Puntos clave

  • Los servicios encapsulan lógica que no pertenece a la presentación: acceso a datos, reglas de negocio y estado compartido.
  • El principio de responsabilidad única dice que un componente debe tener una sola razón para cambiar: cómo se presenta la información.
  • Las tres capas en Angular son presentación (componentes), lógica (servicios) y datos (servicios de acceso a datos o repositorios).
  • Un componente que llama a la API directamente mezcla capas y se vuelve difícil de probar y mantener.
  • La separación en capas no es burocracia: es lo que permite que cada parte del sistema cambie de forma independiente.

¿Qué sigue?

En la Parte 2 creamos nuestro primer servicio completo con @Injectable y vemos las dos formas modernas de inyectarlo en un componente: por constructor y con la función inject().