TL;DR
movie-searcher es una app full stack para buscar películas y consultar su detalle, con un backend que actúa como “proxy” para diferentes servicios de APIs de películas.
Lo importante del proyecto no es solo la UI: es cómo se combinan arquitectura hexagonal + backend reactivo + estado claro en Angular para que la app sea mantenible.
Qué quería resolver de verdad
La necesidad no era únicamente “mostrar resultados”. Quería resolver tres problemas reales:
- Evitar que el frontend hablara directamente con OMDb/TMDb o cualquier otro proovedor en el futuro.
- Poder cambiar de proveedor sin reescribir el dominio.
- Tener una experiencia de búsqueda fluida (carga, error, vacío, éxito) sin lógica caótica en UI.
A partir de ahí, la arquitectura salió casi sola.
Arquitectura hexagonal, pero aplicada (no solo dibujada)
El backend separa el caso de uso de los detalles externos:
- Puerto de entrada:
QueryMovieUseCase - Servicio de aplicación:
SearchMovieService - Puerto de salida:
MovieCatalog - Adaptadores externos:
OmdbMovieCatalogAdapteryTmdbMovieCatalogAdapter
La ventaja práctica: el dominio no sabe si los datos vienen de OMDb o TMDb.
Además, el cambio de proveedor se hace por configuración (movies.provider), apoyado por @ConditionalOnProperty.
Cómo se ve eso en el flujo real
Cuando el usuario busca “interstellar”:
- Angular actualiza la URL (
?search=interstellar&page=1). - El componente reacciona al cambio de query params.
- Llama al backend (
/api/v1/movies). - El controller delega en el caso de uso.
- El caso de uso usa el puerto
MovieCatalog. - Se inyecta el adaptador OMDb o TMDb según configuración.
- Se transforma a modelo de dominio y luego a DTO de salida.
- Se devuelven cabeceras de caché + respuesta final.
Este recorrido mantiene responsabilidades muy claras en ambos lados.
Backend: ejemplo real del controller reactivo
Fragmento real de MovieController:
@Override@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)public Mono<ResponseEntity<MoviePageResponse>> searchByTitle(String query, int page) { return useCase.searchByTitle(query, page) .map(this::toResponse) .map(response -> ResponseEntity.ok() .cacheControl(CacheControl.maxAge(60, TimeUnit.MINUTES) .cachePrivate() .mustRevalidate() ) .header("Vary", "Cookie") .body(response));}
@Override@GetMapping(value = "/{imdbId}", produces = MediaType.APPLICATION_JSON_VALUE)public Mono<ResponseEntity<MovieDetailResponse>> findByImdbId(@PathVariable String imdbId) { return useCase.findByImdbId(imdbId) .map(mapper::toDetail) .map(movie -> ResponseEntity.ok() .cacheControl(CacheControl.maxAge(60, TimeUnit.MINUTES) .cachePrivate() .cachePublic() ) .header("Vary", "Cookie") .body(movie)) .defaultIfEmpty(ResponseEntity.notFound().build());}Lo interesante aquí:
- El controller es fino: delega negocio en
useCase. - Se mantiene un flujo reactivo claro con
Mono. - Se define política de caché HTTP explícita (
Cache-Control,Vary). defaultIfEmptyevita nulls y deja un404limpio.

Detalle de película
También añadí un manejador global para que los errores salgan con un formato consistente (ProblemDetail) y con códigos HTTP correctos.
@ExceptionHandler(ConstraintViolationException.class)public ProblemDetail handleConstraintViolation(ConstraintViolationException ex) { return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());}
@ExceptionHandler(MovieNotFoundException.class)public ProblemDetail handleMovieNotFound(MovieNotFoundException ex) { return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());}
@ExceptionHandler(MethodNotAllowedException.class)public ProblemDetail handleMethodNotAllowed(MethodNotAllowedException ex) { return ProblemDetail.forStatusAndDetail(HttpStatus.METHOD_NOT_ALLOWED, ex.getMessage());}
@ExceptionHandler(AuthenticationException.class)public ProblemDetail handleAuthError(AuthenticationException ex) { return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage());}
@ExceptionHandler(Exception.class)public ProblemDetail handleGenericError(Exception ex) { return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage());}Qué aporta este enfoque:
- Evita respuestas de error incoherentes entre endpoints.
- Mapea casos esperables a códigos concretos (
400,404,405,401). - Deja un fallback para errores no controlados (
500) sin romper el contrato de API.
Frontend Angular: estado de búsqueda bien modelado
Fragmento real de movie-list-page.component.ts:
moviesPageState$ = this.route.queryParamMap.pipe( map((params) => ({ query: (params.get("search") ?? "").trim(), page: +(params.get("page") ?? 1), })), distinctUntilChanged((a, b) => a.query === b.query && a.page === b.page), switchMap(({ query, page }) => { if (!query) { return of<PageState>({ state: "idle" }); }
return this.movies.searchMovies(query, page).pipe( map((page) => ({ state: "loaded", page }) as const), startWith({ state: "loading" } as const), catchError((error) => of({ state: "error", error } as const)), ); }), shareReplay({ bufferSize: 1, refCount: true }),);
this.searchControl.valueChanges .pipe(debounceTime(250), distinctUntilChanged()) .subscribe((value) => { const trimmed = value?.trim(); const queryParams = trimmed ? { search: trimmed, page: 1 } : { search: null, page: null };
void this.router.navigate([], { relativeTo: this.route, queryParams, queryParamsHandling: "merge", }); });Por qué esta parte me gusta:
- El estado de pantalla está modelado como unión (
idle/loading/error/loaded). - La URL es fuente de verdad para búsqueda y paginación.
debounceTimeevita llamadas innecesarias al teclear.shareReplayevita duplicar peticiones cuando hay múltiples consumidores.

Búsqueda de películas
Decisiones técnicas que marcaron diferencia
- Hexagonal de verdad: puertos y adaptadores, no acoplar dominio al proveedor.
- Seguridad pragmática: JWT en cookie
HttpOnlypara no exponer token en JS. - Caché en dos niveles: Caffeine en servidor + cabeceras HTTP al cliente.
- UI robusta: estados explícitos en Angular para evitar comportamientos ambiguos.
- Docker Compose: entorno reproducible para desarrollo y despliegue.
Límites actuales
| Límite | Situación actual | Impacto |
|---|---|---|
| Autenticación básica | Token de usuario "guest", sin cuentas reales ni roles. | Suficiente para demo técnica, corto para producto multiusuario. |
| Dependencia de terceros | Resultados y latencia dependen de OMDb/TMDb. | Una degradación externa puede afectar UX. |
| Funcionalidad de producto | No hay favoritos, historial ni recomendaciones. | La app resuelve consulta, no engagement avanzado. |
| Observabilidad | No hay métricas detalladas por proveedor en la versión actual. | Cuesta más diagnosticar cuellos de botella en producción. |
Conclusión
Me dejó tres aprendizajes claros:
- La arquitectura hexagonal sí aporta valor cuando necesitas integrar proveedores cambiantes.
- La reactividad tiene sentido cuando tu cuello de botella es I/O externo.
- En frontend, modelar bien el estado vale más que añadir componentes sin criterio.
Si hago una siguiente versión, priorizaría: usuarios reales, favoritos persistentes, filtros avanzados y métricas E2E de rendimiento.