June 2025
Projects

Movie Searcher

Movie search app with a reactive backend, hexagonal architecture, and modern Angular frontend.

Java Spring Boot WebFlux Angular TailwindCSS Docker JWT Caffeine

TL;DR

movie-searcher is a full-stack app for searching movies and viewing details, with a backend that acts as a “proxy” for different movie API providers.

What matters in this project is not only the UI: it is how hexagonal architecture + reactive backend + clear Angular state combine to keep the app maintainable.


What I actually wanted to solve

The need was not just “show results.” I wanted to solve three real problems:

  1. Avoid having the frontend talk directly to OMDb/TMDb or any future provider.
  2. Be able to switch providers without rewriting the domain.
  3. Keep search UX smooth (loading, error, empty, success) without chaotic UI logic.

From there, the architecture came almost naturally.


Hexagonal architecture, but applied (not just diagrammed)

The backend separates use-case logic from external details:

  • Input port: QueryMovieUseCase
  • Application service: SearchMovieService
  • Output port: MovieCatalog
  • External adapters: OmdbMovieCatalogAdapter and TmdbMovieCatalogAdapter

Practical benefit: the domain does not know whether data comes from OMDb or TMDb.

Also, provider switching is configuration-based (movies.provider), powered by @ConditionalOnProperty.


How this looks in the real flow

When a user searches for “interstellar”:

  1. Angular updates the URL (?search=interstellar&page=1).
  2. The component reacts to query param changes.
  3. It calls the backend (/api/v1/movies).
  4. The controller delegates to the use case.
  5. The use case calls the MovieCatalog port.
  6. OMDb or TMDb adapter is injected by configuration.
  7. Data is transformed into domain model and then output DTO.
  8. Cache headers + final response are returned.

This path keeps responsibilities very clear on both sides.


Backend: real reactive controller example

Real snippet from 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());
}

What is interesting here:

  • The controller is thin: business logic is delegated to useCase.
  • Reactive flow stays clear with Mono.
  • HTTP cache policy is explicit (Cache-Control, Vary).
  • defaultIfEmpty avoids nulls and provides a clean 404.

Movie detail

Movie detail


I also added a global handler so errors return with consistent format (ProblemDetail) and correct HTTP status codes.

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());
}

What this approach adds:

  • Prevents inconsistent error responses across endpoints.
  • Maps expected cases to specific statuses (400, 404, 405, 401).
  • Provides a fallback for unhandled errors (500) without breaking API contract.

Angular frontend: properly modeled search state

Real snippet from 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",
});
});

Why I like this part:

  • Screen state is modeled as a union (idle/loading/error/loaded).
  • URL is the source of truth for search and pagination.
  • debounceTime avoids unnecessary calls while typing.
  • shareReplay prevents duplicate requests with multiple subscribers.

Movie search

Movie search list


Technical decisions that made a difference

  • Real hexagonal architecture: ports/adapters, domain not coupled to provider.
  • Pragmatic security: JWT in an HttpOnly cookie so token is not exposed to JS.
  • Two-level cache: Caffeine server-side + HTTP headers client-side.
  • Robust UI: explicit Angular states to avoid ambiguous behavior.
  • Docker Compose: reproducible environment for development and deployment.

Current limits

Limit Current state Impact
Basic authentication "Guest" user token only, with no real accounts or roles. Enough for a technical demo, limited for a multi-user product.
Third-party dependency Results and latency depend on OMDb/TMDb. An external degradation can impact UX.
Product functionality No favorites, history, or recommendations. The app solves lookup, not advanced engagement.
Observability No detailed provider-level metrics in the current version. Harder to diagnose production bottlenecks.

Conclusion

It left me with three clear takeaways:

  1. Hexagonal architecture does add value when you need to integrate changing providers.
  2. Reactivity makes sense when your bottleneck is external I/O.
  3. In frontend, modeling state well matters more than adding components without criteria.

If I build a next version, I would prioritize: real users, persistent favorites, advanced filters, and end-to-end performance metrics.