junio de 2025
Proyectos

Movie Searcher

Buscador de películas con backend reactivo, arquitectura hexagonal y frontend Angular moderno.

Java Spring Boot WebFlux Angular TailwindCSS Docker JWT Caffeine

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:

  1. Evitar que el frontend hablara directamente con OMDb/TMDb o cualquier otro proovedor en el futuro.
  2. Poder cambiar de proveedor sin reescribir el dominio.
  3. 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: OmdbMovieCatalogAdapter y TmdbMovieCatalogAdapter

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”:

  1. Angular actualiza la URL (?search=interstellar&page=1).
  2. El componente reacciona al cambio de query params.
  3. Llama al backend (/api/v1/movies).
  4. El controller delega en el caso de uso.
  5. El caso de uso usa el puerto MovieCatalog.
  6. Se inyecta el adaptador OMDb o TMDb según configuración.
  7. Se transforma a modelo de dominio y luego a DTO de salida.
  8. 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:

adapters/in/rest/movie/MovieController.java
@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).
  • defaultIfEmpty evita nulls y deja un 404 limpio.

Detalle de película

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.

adapters/in/rest/error/GlobalExceptionHandler.java
@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:

modules/movies/list/pages/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.
  • debounceTime evita llamadas innecesarias al teclear.
  • shareReplay evita duplicar peticiones cuando hay múltiples consumidores.

Búsqueda de película

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 HttpOnly para 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:

  1. La arquitectura hexagonal sí aporta valor cuando necesitas integrar proveedores cambiantes.
  2. La reactividad tiene sentido cuando tu cuello de botella es I/O externo.
  3. 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.