Desentrañando un Angular AuthService: Gateway, Store, Facade y Puertos
Dividir un AuthService de Angular sobredimensionado en una pasarela, un almacén de señales, un puerto de almacenamiento y una fachada, con actualización de tokens de un solo uso, un interceptor escopado y guardias probables.
Fuentes: Clean Architecture in Angular: Refactoring the AuthService with Ports & Adapters
Casi toda aplicación Angular cuenta con un AuthService, y en muchos proyectos es el archivo al que nadie quiere tocar. Combina llamadas HTTP, persistencia de tokens, estado reactivo, lógica de actualización y navegación, por lo que modificar cualquiera de estos componentes implica revisarlos todos. Este artículo explica cómo reestructurar dicho servicio en capas pequeñas con dependencias unidireccionales, y muestra dos errores reales que las nuevas divisiones revelaron: un fallo en la actualización que evitó el cierre de sesión, y un interceptor que filtró tokens de autenticación a APIs de terceros. Al final obtendrás un esquema que puedes aplicar a tu propia funcionalidad de autenticación, además de una idea clara de cuándo no vale la pena crear archivos adicionales.
Cómo surge el “objeto divino” AuthService
Nadie diseña intencionalmente un servicio de autenticación con 300 líneas de código. Comienza como un método login() que llama a un único endpoint. Luego es necesario guardar el token en algún lugar, por lo que aparece un campo para ello. El token debe mantenerse después de una recarga, así que se añade localStorage. A continuación vienen los tokens de actualización, luego un BehaviorSubject para que los componentes puedan reaccionar al estado de inicio de sesión, y finalmente un router.navigate('/login') al cerrar la sesión, ya que no parecía haber un lugar mejor para ello. Un año y medio después, la clase importa Router, HttpClient y localStorage, y para cambiar la estrategia de almacenamiento del token es necesario comprender todo el archivo.
Una versión condensada de ese punto de partida se ve así. Preste atención a cuántas cuestiones no relacionadas comparten la misma llamada tap en login(): actualizar un campo, escribir dos claves de almacenamiento, emitir estado y navegar.
@Injectable({ providedIn: 'root' })
export class AuthService {
private token: string | null = localStorage.getItem('token');
public isLoggedIn$ = new BehaviorSubject(!!this.token);
constructor(
private http: HttpClient,
private router: Router,
) {}
login(username: string, password: string) {
return this.http
.post<any>('https://api.example.com/auth/login', { username, password })
.pipe(
tap((res) => {
this.token = res.accessToken;
localStorage.setItem('token', res.accessToken);
localStorage.setItem('refresh', res.refreshToken);
this.isLoggedIn$.next(true);
this.router.navigate(['/dashboard']);
}),
);
}
// ...and 250 more lines
}
Nada de esto está técnicamente roto, y ahí está la trampa. Funciona hasta que cambia un requisito, y entonces cada cuestión termina entrelazada con todas las demás. El resto de este artículo sigue la reestructuración de la función de autenticación en un pequeño proyecto Angular 22. La estructura resultante no es nueva, y cada componente tiene un nombre bien conocido, pero aplicarla a código concreto y errores reales hace que la explicación sea más clara que un diagrama.
El layout objetivo
La única clase se divide en varios archivos especializados, agrupados en una carpeta core transversal y una carpeta específica para la autenticación.
src/app/
core/
api-config.ts ← injectable base URL
auth-interceptor.ts ← cross-cutting: attach token, retry on 401
auth/
auth-models.ts ← types
auth-api.ts ← HTTP transport, nothing else
auth-store.ts ← state, and only state
auth-storage.ts ← storage port + adapters
auth-service.ts ← orchestration (the facade)
auth-guard.ts ← route-level policy
login/
dashboard/
Tener siete archivos pequeños en lugar de uno grande solo es una buena opción si cada archivo resulta más fácil de comprender por separado que el original en su conjunto. Una prueba práctica es si se puede describir la función de cada archivo en una sola oración. Aquí eso es posible, y el diagrama de dependencias es lo suficientemente breve como para dibujarse en unas pocas líneas:
auth-interceptor → auth-service → auth-api (talks to the network)
(cross-cutting) (facade) → auth-store (holds the truth)
→ auth-models (describes the shapes)
Cada flecha apunta hacia la derecha. Esa regla, de que las dependencias fluyen en una sola dirección sin ciclos, es lo que mantiene unido el diseño. Cada sección a continuación es una consecuencia de ello.
La pasarela: conocimientos de red en un solo lugar
La capa más baja encapsula los puntos de extremo HTTP y no hace nada más. Cada método asigna sus argumentos a una solicitud tipada y devuelve el resultado observable.
@Service()
export class AuthApi {
private readonly httpClient = inject(HttpClient);
private readonly baseUrl = inject(API_BASE_URL);
public login(request: LoginInput) {
return this.httpClient.post<AuthResponse>(`${this.baseUrl}/auth/login`, request);
}
public refresh(refreshToken: string, expiresInMins: number) {
return this.httpClient.post<RefreshResponse>(`${this.baseUrl}/auth/refresh`, {
refreshToken,
expiresInMins,
});
}
public me() {
return this.httpClient.get<User>(`${this.baseUrl}/auth/me`);
}
}
Este es el patrón Gateway de Patterns of Enterprise Application Architecture de Martin Fowler. A menudo se le denomina repositorio, pero un repositorio implica una abstracción similar a un conjunto que se puede consultar. Un envoltorio tipado alrededor de llamadas remotas se describe con mayor precisión como un gateway.
La ventaja radica en el encierro del conocimiento sobre los protocolos. Si el punto de acceso de inicio de sesión pasa de /auth/login a /v2/sessions, la modificación se limita a esta clase; el resto de la aplicación nunca maneja una URL.
Lo que falta es tan importante como lo que existe: no hay estado, no hay localStorage, no hay Router, y no hay manejo de errores más allá de lo que proporciona HTTP en sí. Cada método simplemente convierte sus argumentos en un Observable inerte, lo que hace que sea sencillo reemplazar la clase por una falsa en las pruebas.
La URL base se injeta en lugar de estar codificada de forma fija, a través de un token de inyección:
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');
Un token para una única cadena puede parecer algo complejo. En realidad no lo es, y la sección de interceptores explica por qué acaba siendo una de las líneas más importantes de esta funcionalidad.
Una nota adicional sobre el decorador: los ejemplos utilizan @Service() tal como está definido en el proyecto. Si su versión de Angular no lo ofrece, @Injectable({ providedIn: 'root' }) cumple la misma función; consulte la documentación de la versión que está utilizando.
El almacén: un único responsable del estado de la sesión
El almacén contiene la información de la sesión y es el único elemento autorizado para modificarla. Almacena el token de acceso, el token de renovación y al usuario en señales.
@Service()
export class AuthStore {
private readonly storage = inject(AUTH_STORAGE);
private readonly accessTokenState = signal<string | null>(null);
private readonly refreshTokenState = signal<string | null>(null);
private readonly userState = signal<User | null>(null);
readonly accessToken = this.accessTokenState.asReadonly();
readonly refreshToken = this.refreshTokenState.asReadonly();
readonly user = this.userState.asReadonly();
readonly isAuthenticated = computed(() => this.accessTokenState() !== null);
public setTokens(access: string, refresh: string): void {
this.accessTokenState.set(access);
this.refreshTokenState.set(refresh);
this.persist();
}
public clear(): void {
this.accessTokenState.set(null);
this.refreshTokenState.set(null);
this.userState.set(null);
this.storage.removeItem(STORAGE_KEY);
}
// ...
}
El detalle más importante es fácil de pasar por alto: cada señal escritible es privada, y toda señal pública se expone a través de asReadonly().
Esa es la separación entre comandos y consultas que impone el sistema de tipos. Cualquiera puede leer; la escritura solo ocurre a través de métodos cuyo nombre refleja una intención, como setTokens y clear. Un componente no puede establecer un token directamente. Esto no es una convención que deba seguirse; es imposible, ya que el tipo Signal<T> de solo lectura no cuenta con un método set().
Los beneficios se notan durante la depuración. La pregunta “¿Quién cambió este estado?” deja de ser una investigación, porque solo unos pocos métodos nombrados en un archivo pueden modificar la sesión. Compare esto con un BehaviorSubject público, donde cualquiera de las decenas de componentes podría haber llamado a next().
La tienda también es la única capa que interactúa con la persistencia, y trata todo lo que lee como entrada no fiable.
private restore(): void {
const raw = this.storage.getItem(STORAGE_KEY);
if (!raw) return;
// Storage is user-writable, so treat anything in it as untrusted input.
try {
const session = JSON.parse(raw) as Partial<PersistedSession>;
if (typeof session.accessToken !== 'string' || typeof session.refreshToken !== 'string') {
this.storage.removeItem(STORAGE_KEY);
return;
}
this.accessTokenState.set(session.accessToken);
this.refreshTokenState.set(session.refreshToken);
this.userState.set(session.user ?? null);
} catch {
this.storage.removeItem(STORAGE_KEY);
}
}
localStorage es, en efecto, un campo de texto que el usuario puede editar, y las extensiones del navegador o versiones antiguas de tu propia aplicación pueden dejar datos inesperados en él. Llamar a JSON.parse sin un bloque try/catch conlleva el riesgo de que la aplicación se cierre al iniciarse, y el modo estricto de TypeScript no te emitirá advertencias, ya que JSON.parse devuelve un tipo any. Verificar su estructura y eliminar la entrada cuando no coincide impide que una sesión dañada desconecte al usuario en lugar de dejarlo mirando una pantalla en blanco.
La interfaz de almacenamiento: hacer que la persistencia sea intercambiable
La dependencia AUTH_STORAGE en el almacén no es localStorage en sí. Se trata de una interfaz, con un token de inyección cuya fábrica por defecto elige una implementación.
export interface AuthStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
export const AUTH_STORAGE = new InjectionToken<AuthStorage>('AUTH_STORAGE', {
providedIn: 'root',
factory: () => (typeof localStorage === 'undefined' ? new MemoryStorage() : localStorage),
});
Esa es la idea de Puertos y Adaptadores (arquitectura hexagonal) reducida a sus elementos esenciales. La interfaz, AuthStorage, define el puerto. El almacenamiento real del navegador se conecta a él como un adaptador, y la clase MemoryStorage de memoria, compuesta por unas pocas líneas, se conecta como otro adaptador.
El puerto resuelve tres problemas concretos:
- Las pruebas utilizan un almacén real. Al inyectar
MemoryStoragese puede probar el comportamiento real de persistencia, como la escritura, la recarga y la restauración, sin necesidad de un estado global que deba limpiarse entre casos de prueba.
localStorage no existe en el servidor, por lo que la solución recurre automáticamente al almacenamiento en memoria.localStorage significa que cualquier script ejecutado en la página puede leerlo, lo cual representa una verdadera exposición a XSS. La solución sólida es utilizar una cookie httpOnly establecida por el backend. Al almacenarla detrás de un puerto, esa migración solo afecta a un adaptador en lugar de requerir una búsqueda y reemplazo en todo el código, y hasta que se realice, el riesgo permanece en un archivo con un comentario que lo explica.El tercer punto es más general. Una buena arquitectura no convierte una mala decisión en una buena; hace que la decisión sea local, visible y fácil de revertir.
La fachada: orquestación sin responsabilidad
AuthService sigue existiendo, pero con una función mucho más limitada. Coordina la pasarela y el almacén sin poseer nada por sí mismo.
@Service()
export class AuthService {
private readonly authApi = inject(AuthApi);
private readonly store = inject(AuthStore);
readonly accessToken = this.store.accessToken;
readonly isAuthenticated = this.store.isAuthenticated;
readonly user = this.store.user;
public login(username: string, password: string): Observable<AuthResponse> {
return this.authApi.login({ username, password, expiresInMins: TOKEN_LIFETIME_MINUTES }).pipe(
tap((response) => {
this.store.setTokens(response.accessToken, response.refreshToken);
this.store.setUser({ /* ...map response to User... */ });
}),
);
}
public logout(): void {
this.store.clear();
}
}
Se trata de la fachada del Gang of Four, que también actúa como mediador: es la única capa que sabe que tanto la pasarela como el almacén existen.
Volver a exportar las señales del almacén desde la fachada es una acción sencilla con un gran impacto: los componentes dependen únicamente de AuthService y no tienen conocimiento de que existe un AuthStore separado. Esto convierte al almacén en un detalle de implementación de la funcionalidad, por lo que se podría reemplazar posteriormente con NgRx sin que sea necesario modificar ningún componente.
Tenga en cuenta que Router sigue estando ausente. La fachada no se encarga de la navegación, ya que esta es una responsabilidad de presentación. La página de inicio de sesión desea dirigirse a returnUrl, el botón de cierre de sesión del panel de control quiere ir a /login, y un diálogo por expiración de sesión podría no navegar en absoluto. Llamar a router.navigate() dentro de logout() forzaría una política para todos los que lo invoquen. En su lugar, el componente que llama decide:
protected logout(): void {
this.auth.logout();
void this.router.navigateByUrl('/login');
}
El costo es de dos líneas en el componente. A cambio, AuthService puede ser llamado desde interceptores, protectores y pruebas sin provocar una redirección que nadie solicitó.
Unir actualizaciones concurrentes en una sola solicitud
El argumento más sólido a favor de una fachada es un problema que resulta difícil resolver en cualquier otro lugar. Un panel de control envía ocho solicitudes en paralelo. El token de acceso expiró hace medio minuto, por lo que las ocho solicitudes devuelven un código 401 casi al mismo tiempo. Un interceptor ingenuo responde iniciando ocho llamadas de actualización. Muchos servidores backend rotan el token de actualización cada vez que se utiliza, así que para cuando llegan las llamadas posteriores, el token que llevan ya ha sido invalidado y el usuario queda desconectado de la página que abrió hace segundos.
La solución es el método single-flight, también conocido como agrupación de solicitudes o prevención del efecto rebaño: el primer solicitante inicia la actualización, y todos los demás se unen a la solicitud que ya está en curso. La misma idea aplicada en Axios se explica en por qué los 401 concurrentes desconectan a los usuarios.
public refresh(): Observable<RefreshResponse> {
const refreshToken = this.store.refreshToken();
if (!refreshToken) {
return throwError(() => new Error('No refresh token available'));
}
this.refreshRequest$ ??= this.authApi.refresh(refreshToken, TOKEN_LIFETIME_MINUTES).pipe(
tap((response) => this.store.setTokens(response.accessToken, response.refreshToken)),
finalize(() => (this.refreshRequest$ = undefined)),
shareReplay({ bufferSize: 1, refCount: true }),
);
return this.refreshRequest$;
}
Los tres operadores deben aparecer en este orden, por tres razones distintas:
tapalmacena los nuevos tokens. Al estar antes queshareReplay, se ejecuta una vez por cada ejecución compartida y no una vez por cada suscriptor. Si se colocara después, reescribiría el almacenamiento para cada uno de los ocho llamantes.finalizerestablece el campo en caché una vez que la solicitud compartida finaliza o se producen errores. Sin él, cuando venza el plazo de un token posterior, se reproduciría la respuesta antigua ya utilizada en lugar de generar una nueva llamada.shareReplay({ bufferSize: 1, refCount: true })envía de forma multicanal una única llamada HTTP a todos los suscriptores y la cancela una vez que el último suscriptor se retira.
La asignación ??= es lo que hace que sea de un solo vuelo: solo quien llama y encuentra el campo vacío crea una solicitud, y todos los que llegan mientras está pendiente reciben ese mismo observable. El resultado es una sola llamada a la red y un solo cambio de token.
Un lanzamiento síncrono que omitió el cierre de sesión
El uso de throwError en el método de actualización no es una elección estilística. La versión anterior de la cláusula de protección se veía así:
if (!refreshToken) throw new Error('No refresh token available');
Se lee de forma natural, pero está rota. El límite entre capas fue lo que hizo visible el error. El interceptor consume refresh() de esta manera:
return auth.refresh().pipe(
switchMap(/* retry the original request */),
catchError((refreshError) => {
auth.logout();
return throwError(() => refreshError);
}),
);
Un throw síncrono dentro de refresh() se dispara mientras aún se está evaluando la expresión auth.refresh(), antes de que se llame nunca a .pipe(). El operador catchError nunca tiene la oportunidad de ver el error, por lo que auth.logout() nunca se ejecuta. La única situación para la cual estaba diseñada la cláusula de protección, un token de actualización faltante, era exactamente la situación en la que el usuario quedaba silenciosamente parcialmente autenticado.
Al devolver throwError(() => ...) se convierte el fallo en una notificación de error en la secuencia, de modo que el分支 de cierre de sesión del interceptor se activa como estaba previsto. Como regla general, un método cuya firma devuelve un Observable debería reportar los fallos a través de dicho observable, y no mediante un throw.
La lección arquitectónica es que este error se oculta fácilmente en una clase de 300 líneas donde el manejo de HTTP, el estado y los errores están entrelazados, ya que nada obliga a nadie a definir qué promete un método determinado a quienes lo invocan. Cuando refresh() se convierte en un método de fachada público con una función clara, la pregunta “¿devuelve este método un flujo fallido o lanza una excepción?” se vuelve evidente, al igual que la prueba correspondiente:
it('emits an error stream instead of throwing when there is no refresh token', () => {
const onError = vi.fn();
// A synchronous throw here would bypass the interceptor's catchError/logout branch.
expect(() => service.refresh().subscribe({ error: onError })).not.toThrow();
expect(onError).toHaveBeenCalledOnce();
});
Las capas no previenen los errores. Solo les brindan un lugar donde se vuelven visibles, y ese es su principal valor.
El interceptor: reglas transversales y un token de ámbito
El interceptor aplica las reglas de autenticación a cada solicitud saliente: adjunta el token portador y, en caso de 401, actualiza la información y vuelve a intentarlo una vez.
const UNAUTHENTICATED_PATHS = ['/auth/login', '/auth/refresh'];
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const baseUrl = inject(API_BASE_URL);
if (!isOurApi(req, baseUrl) || isUnauthenticatedRequest(req)) {
return next(req);
}
const auth = inject(AuthService);
const accessToken = auth.accessToken();
const authReq = accessToken ? withBearer(req, accessToken) : req;
return next(authReq).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status !== 401) {
return throwError(() => error);
}
return auth.refresh().pipe(
switchMap(() => {
const newToken = auth.accessToken();
if (!newToken) {
return throwError(() => new Error('Refresh succeeded without token'));
}
return next(withBearer(req, newToken));
}),
catchError((refreshError) => {
auth.logout();
return throwError(() => refreshError);
}),
);
}),
);
};
La cadena de interceptores de Angular es una implementación directa del Principio de Cadena de Responsabilidades, y cada función interceptor decora la solicitud a medida que esta pasa por ella. Lo más importante es que la política que aplica (adjuntar credenciales y tratar un error 401 como señal para actualizarlas e intentarlo de nuevo) se aplica a toda la interfaz de la API, lo que la convierte en un ejemplo clásico de preocupación transversal. No pertenece a ningún llamador individual. Sin los interceptores, cada método de servicio que accede a un endpoint autenticado tendría que repetir constantemente el proceso de inserción del token y los intentos de recuperación, y en el momento en que la lógica de reintentos tenga un error sutil en una de las treinta copias, se generaría un bug que aparecería una vez por semana en producción pero nunca en el entorno de desarrollo. Para conocer formas más amplias de manejar solicitudes fallidas en Angular, consulte diseñando para solicitudes fallidas en Angular.
Ahora observe con atención la cláusula de protección en la parte superior de la función:
if (!isOurApi(req, baseUrl) || isUnauthenticatedRequest(req)) {
return next(req);
}
Es aquí donde resulta útil el API_BASE_URL inyectado. Soluciona dos problemas reales:
- Fuga de tokens a otros orígenes. Un interceptor que no verifica la URL adjunta el token de acceso del usuario a cada solicitud saliente, incluidas las subidas a almacenamiento de objetos, proveedores de tiles de mapas y puntos finales de análisis. Esto entrega una credencial a terceros no relacionados simplemente porque el interceptor fue demasiado entusiasta.
- Solicitudes de actualización que llevan el token vencido. Sin la lista de exclusiones, la solicitud de actualización en sí se envía con el token de acceso obsoleto adjunto. Un backend indulgente lo ignora; uno estricto responde con otro error 401, y esto puede llevar a un bucle de actualizaciones.
Ambos son soluciones de una sola línea una vez que la URL base es injetable. Sin ella, el interceptor no cuenta con una forma fiable de determinar cuáles solicitudes son las suyas.
Otro detalle que merece atención es el siguiente: la solicitud de reintento se envía mediante next(...), y no reiniciando la cadena de interceptores desde el principio. Si la solicitud reintentada también devuelve 401, el error se propaga una sola vez al llamante y se detiene, por lo que el interceptor no puede entrar en bucle.
Guardias: acceso a rutas como política
La última capa responde a una única pregunta para el router: ¿puede proceder esta navegación?
export const authGuard: CanActivateFn = (_route, state) => {
const store = inject(AuthStore);
const router = inject(Router);
if (store.isAuthenticated()) {
return true;
}
return router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
};
export const guestGuard: CanActivateFn = () => {
const store = inject(AuthStore);
const router = inject(Router);
return store.isAuthenticated() ? router.createUrlTree(['/dashboard']) : true;
};
En términos de seguridad, una protección representa un punto de aplicación de políticas: la decisión de acceso se toma en un único lugar. Sin ella, las verificaciones de autenticación tienden a quedar dispersas en los constructores de componentes, se olvidan en las rutas añadidas bajo presión de plazos y generan pantallas que muestran brevemente una interfaz que parece privada antes de redirigir.
Devolver un UrlTree en lugar de navegar de forma imperativa es intencional. El router abandona el intento de navegación y realiza la redirección en un solo paso, de modo que la ruta protegida nunca aparece en pantalla. La segunda protección, guestGuard, funciona de forma opuesta: impide que los usuarios autenticados accedan a la página de inicio de sesión.
Luego, estas protecciones se vinculan a las rutas:
export const routes: Routes = [
{
path: 'login',
canActivate: [guestGuard],
loadComponent: () => import('./login/login').then((m) => m.Login),
},
{
path: 'dashboard',
canActivate: [authGuard],
loadComponent: () => import('./dashboard/dashboard').then((m) => m.Dashboard),
},
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
{ path: '**', redirectTo: 'dashboard' },
];
El parámetro de consulta returnUrl establecido por authGuard llega al componente de inicio de sesión sin necesidad de ningún código manual con ActivatedRoute. Con provideRouter(routes, withComponentInputBinding()), los parámetros de consulta se vinculan a las entradas de señal correspondientes:
readonly returnUrl = input('/dashboard');
// ...
this.auth.login(username, password).subscribe({
next: () => this.router.navigateByUrl(this.returnUrl()),
error: () => { /* ... */ },
});
Un detalle revelador del proyecto: antes de que existiera la protección, el almacén ya exponía isAuthenticated, una señal calculada que no se leía en ningún momento. Un estado sin usar de este tipo es señal de un diseño inacabado. No es inocuo; representa una promesa que la aplicación aún no ha cumplido.
Lo que te ofrecen las capas en las pruebas
La ventaja más evidente es la poca configuración que requieren las pruebas. No hay accesorios para componentes, ni limpieza de localStorage, ni simulaciones manuales de HttpClient. La configuración que se muestra a continuación proporciona el backend de pruebas HTTP de Angular y reemplaza exactamente dos tokens: la URL base y el puerto de almacenamiento.
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: API_BASE_URL, useValue: BASE_URL },
{ provide: AUTH_STORAGE, useValue: new MemoryStorage() },
],
});
service = TestBed.inject(AuthService);
store = TestBed.inject(AuthStore);
http = TestBed.inject(HttpTestingController);
});
it('shares one in-flight request across concurrent refresh calls', () => {
store.setTokens('access-1', 'refresh-1');
service.refresh().subscribe();
service.refresh().subscribe();
const request = http.expectOne(`${BASE_URL}/auth/refresh`);
request.flush({ accessToken: 'access-2', refreshToken: 'refresh-2' });
expect(store.accessToken()).toBe('access-2');
});
La prueba de un solo vuelo llama a refresh() dos veces y luego utiliza http.expectOne, el cual falla si se envía más de una solicitud que coincida. Esto verifica la garantía de coalescencia en pocas líneas, y sigue verificándola incluso si alguien refactorea refresh() dentro de un año. Los dos tokens inyectados constituyen todo el conjunto de pruebas, que es exactamente para lo que estaban diseñados los puertos.
Qué no es este diseño
Estar preciso respecto a los límites de un patrón es tan útil como nombrarlo.
No se trata de Redux ni de NgRx. No existen acciones, reducers, efectos ni ciclo de dispatch. AuthStore es un estado encapsulado con métodos nombrados según su finalidad. Si necesita depuración mediante viajes en el tiempo, coordinación de efectos entre diferentes funcionalidades o un registro serializado de acciones para reproducirlas, opte por NgRx; la capa de fachada impide que los componentes se den cuenta del cambio.
Tampoco sigue una Arquitectura Limpia completa. Aquí no encontrará clases de casos de uso, y el modelo de dominio no está separado del formato de comunicación. El tipo User en auth-models.ts es simplemente la estructura de la respuesta de la API. Un enfoque estricto de Arquitectura Limpia definiría tipos separados y realizaría la conversión entre ellos. Dado que solo hay unos pocos campos involucrados, esa capa de mapeo cuesta más de lo que aporta, pero si la estructura de la API y su modelo de dominio comienzan a divergir, ese es el punto en el que se debería introducir dicha arquitectura.
No es gratuito. Siete archivos, varias indirecciones y un token de inyección para una única cadena representan costos reales. En un prototipo con dos pantallas esto constituye sobreingeniería, y escribir un servicio grande único es la decisión correcta. El punto de equilibrio suele llegar cuando dos funcionalidades necesitan el mismo estado de sesión, o cuando por primera vez hay que explicar la lógica de actualización a un compañero de equipo.
Conclusión
Todo el diseño se reduce a una regla: las dependencias fluyen en una sola dirección, y cada aspecto específico se encuentra exactamente en un archivo.
interceptor → service → { api, store } → models
En la práctica, eso significa: la pasarela no tiene acceso al estado, el almacén no realiza operaciones de red, la fachada deja la gestión del enrutamiento a quienes la llaman, y los componentes solo comunican con la fachada. Ninguna capa se comunica hacia arriba en la cadena.
Esta restricción es la que convirtió un throw síncrono derivado de un cierre parcial oculto en una prueba que falló rápidamente. Hizo que la pregunta “¿qué solicitudes llevan el token de autenticación?” tuviera una respuesta de una sola línea. Además, transforma un futuro cambio de localStorage a cookies httpOnly en una simple modificación de un adaptador, en lugar de tener que buscar por todo el código.
Los nombres de estos patrones suenan grandiosos: Gateway, Facade, Ports and Adapters, Chain of Responsibility, Policy Enforcement Point. En realidad, todos expresan la misma idea: asignar a cada decisión un lugar específico y hacer evidente la dirección de las dependencias. Todo lo demás se deriva de eso.
Lecturas relacionadas
- Cómo Angular Inyección de Dependencias Resuelve Servicios con inject() — Entienda cómo Angular DI suministra servicios a través de inject(), providers e inyectores jerárquicos, y cómo delimitar instancias y evitar errores por proveedor faltante.
- Diseño para Solicitudes Fallidas en Angular: Estados, Interceptores y Reintentos — Aprenda cómo clasificar los fallos HTTP en Angular, limpiar el estado de carga, centralizar el manejo en interceptores, reintentar de forma segura y mostrar mensajes con los que los usuarios puedan actuar.