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:
- Avoid having the frontend talk directly to OMDb/TMDb or any future provider.
- Be able to switch providers without rewriting the domain.
- 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:
OmdbMovieCatalogAdapterandTmdbMovieCatalogAdapter
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”:
- Angular updates the URL (
?search=interstellar&page=1). - The component reacts to query param changes.
- It calls the backend (
/api/v1/movies). - The controller delegates to the use case.
- The use case calls the
MovieCatalogport. - OMDb or TMDb adapter is injected by configuration.
- Data is transformed into domain model and then output DTO.
- Cache headers + final response are returned.
This path keeps responsibilities very clear on both sides.
Backend: real reactive controller example
Real snippet from 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());}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). defaultIfEmptyavoids nulls and provides a clean404.

Movie detail
I also added a global handler so errors return with consistent format (ProblemDetail) and correct HTTP status codes.
@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:
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.
debounceTimeavoids unnecessary calls while typing.shareReplayprevents duplicate requests with multiple subscribers.

Movie search list
Technical decisions that made a difference
- Real hexagonal architecture: ports/adapters, domain not coupled to provider.
- Pragmatic security: JWT in an
HttpOnlycookie 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:
- Hexagonal architecture does add value when you need to integrate changing providers.
- Reactivity makes sense when your bottleneck is external I/O.
- 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.