The end result of the code developed in this document can be found in the GitHub monorepo springboot-demo-projects, under the tag first-endpoint.
The application now starts and responds with a 404 on every route. Time to give it something real to do. This document builds a single endpoint, GET /api/films/{id}, following hexagonal architecture from day one. The data is hardcoded for now; the goal is to establish the structure that the rest of the application will grow into.
The endpoint returns a film that looks like this:
{ "id":42, "title":"ACADEMY DINOSAUR", "description":"A Epic Drama of a Feminist And a Mad Scientist who must Battle a Teacher in The Canadian Rockies", "releaseYear":2006, "rating":"PG", "length":86, "language":"English" }
Everything new lives under the film feature. The domain layer defines the model and the port interface. The adapter layer handles HTTP concerns. Nothing in the domain knows that HTTP exists.
Before writing any code, set up Spotless to enforce consistent formatting across the codebase. Running ./gradlew build will automatically format your code before compiling.
Java and Groovy modules also need a greclipse.properties file to configure indentation:
The domain layer is the heart of the application. It owns the business model and declares what operations are available, without knowing anything about databases, HTTP, or any other infrastructure detail.
Film is the central domain model. It represents a film as the application understands it: not as a database row, not as an HTTP response, just a plain data object.
package dev.pollito.spring_kotlin.sakila.film.domain.model dataclassFilm( val id: Int, val title: String, val description: String, val releaseYear: Int, val rating: String, val length: Int, val language: String, )
package dev.pollito.spring_groovy.sakila.film.domain.port.in import dev.pollito.spring_groovy.sakila.film.domain.model.Film interfaceFindByIdPortIn{ Film findById(Integer id) }
The interface lives in the domain layer. The REST controller will depend on this interface, not on any concrete implementation. That indirection is what makes the architecture flexible.
FindByIdPortInImpl is the implementation of that contract. For now, it returns a hardcoded film regardless of the ID provided. The real database-backed implementation comes in a later document.
packagedev.pollito.spring_java.sakila.film.domain.port.in; importdev.pollito.spring_java.sakila.film.domain.model.Film; importorg.springframework.stereotype.Service; @Service publicclassFindByIdPortInImplimplementsFindByIdPortIn{ @Override publicFilmfindById(Integer id){ returnFilm.builder() .id(id) .title("ACADEMY DINOSAUR") .description( "A Epic Drama of a Feminist And a Mad Scientist who must Battle a Teacher in The Canadian Rockies") .releaseYear(2006) .rating("PG") .length(86) .language("English") .build(); } }
package dev.pollito.spring_kotlin.sakila.film.domain.port.`in` import dev.pollito.spring_kotlin.sakila.film.domain.model.Film import org.springframework.stereotype.Service @Service class FindByIdPortInImpl : FindByIdPortIn { overridefunfindById(id: Int): Film { returnFilm( id = id, title ="ACADEMY DINOSAUR", description = "A Epic Drama of a Feminist And a Mad Scientist who must Battle a Teacher in The Canadian Rockies", releaseYear =2006, rating ="PG", length =86, language ="English", ) } }
package dev.pollito.spring_groovy.sakila.film.domain.port.in import dev.pollito.spring_groovy.sakila.film.domain.model.Film import groovy.transform.CompileStatic import org.springframework.stereotype.Service @Service @CompileStatic classFindByIdPortInImplimplementsFindByIdPortIn{ @Override Film findById(Integer id){ newFilm( id: id, title:"ACADEMY DINOSAUR", description:"A Epic Drama of a Feminist And a Mad Scientist who must Battle a Teacher in The Canadian Rockies", releaseYear:2006, rating:"PG", length:86, language:"English" ) } }
The hardcoded data is intentional. It lets you verify that the full request-response cycle works end-to-end before introducing any persistence complexity.
The adapter layer translates between the domain and the outside world. For an inbound REST adapter, that means receiving HTTP requests, calling the domain, and returning HTTP responses.
FilmResponse is what the API exposes over the wire. It mirrors Film field-for-field at this stage, but keeping them separate matters: the domain model can evolve independently from the API contract.
package dev.pollito.spring_kotlin.sakila.film.adapter.`in`.rest.dto dataclassFilmResponse( val id: Int, val title: String, val description: String, val releaseYear: Int, val rating: String, val length: Int, val language: String, )
FilmRestMapper converts a Film domain model into a FilmResponse DTO. This translation responsibility belongs here, not in the controller or in the domain.
FilmRestController ties it all together. It handles the GET /api/films/{id} route, delegates to the primary port, and maps the result to a response DTO.
The controller depends only on FindByIdPortIn (the interface) and FilmRestMapper. It has no knowledge of how FindByIdPortIn is implemented. Today it's hardcoded data; later it will be a database query. The controller won't change either way.
Build and run the application, then hit the endpoint:
Terminal
curl -s http://localhost:8080/api/films/42 | jq { "id": 42, "title": "ACADEMY DINOSAUR", "description": "A Epic Drama of a Feminist And a Mad Scientist who must Battle a Teacher in The Canadian Rockies", "releaseYear": 2006, "rating": "PG", "lengthMinutes": 86, "language": "English" }
The architecture is in place, but FindByIdPortInImpl returns the same hardcoded film for every ID. The next step is replacing that with a real database query, which means wiring in a secondary port, a JPA repository, and an entity-to-domain mapper. The domain layer won't change at all.