Skip to main content

First endpoint

Complete Code
The end result of the code developed in this document can be found in the GitHub monorepo springboot-demo-projects, commit(s) 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"
}

New files

Files to Create/Modify
File Tree
.
├── build.gradle
├── greclipse.properties
├── ...
└── src
├── main
│ ├── java
│ │ └── dev/pollito/spring_java
│ │ ├── sakila
│ │ │ └── film
│ │ │ ├── adapter
│ │ │ │ └── in/rest
│ │ │ │ ├── dto
│ │ │ │ │ └── FilmResponse.java
│ │ │ │ ├── FilmRestController.java
│ │ │ │ └── FilmRestMapper.java
│ │ │ └── domain
│ │ │ ├── model
│ │ │ │ └── Film.java
│ │ │ ├── port/in
│ │ │ │ └── FilmUseCases.java
│ │ │ └── service
│ │ │ └── FilmUseCasesImpl.java
│ │ └── ...
│ └── ...
└── ...

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.

Set up the code formatter

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:

greclipse.properties
org.eclipse.jdt.core.formatter.tabulation.char=space
org.eclipse.jdt.core.formatter.tabulation.size=2
org.eclipse.jdt.core.formatter.indentation.size=2

Then update build.gradle (or build.gradle.kts) to register the Spotless plugin and wire it into the build lifecycle:

build.gradle (plugins block)
id 'com.diffplug.spotless' version '8.2.1'
build.gradle (new spotless block)
spotless {
java {
target 'src/*/java/**/*.java'
googleJavaFormat()
removeUnusedImports()
cleanthat()
formatAnnotations()
}
groovyGradle {
target '*.gradle'
greclipse().configFile('greclipse.properties')
}
}

tasks.named("build") {
dependsOn 'spotlessApply'
dependsOn 'spotlessGroovyGradleApply'
}

With this in place, formatting is no longer a manual step or a code review concern. Every build produces consistently formatted code.

Domain layer

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.

Domain model

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.

java/dev/pollito/spring_java/sakila/film/domain/model/Film.java
package dev.pollito.spring_java.sakila.film.domain.model;

import static lombok.AccessLevel.*;

import lombok.*;
import lombok.experimental.FieldDefaults;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@FieldDefaults(level = PRIVATE)
public class Film {
Integer id;
String title;
String description;
Integer releaseYear;
String rating;
Integer length;
String language;
}

Primary port

A port is a contract. The primary port (FilmUseCases) declares what the domain exposes to the outside world: given an ID, return a Film.

java/dev/pollito/spring_java/sakila/film/domain/port/in/FilmUseCases.java
package dev.pollito.spring_java.sakila.film.domain.port.in;

import dev.pollito.spring_java.sakila.film.domain.model.Film;

public interface FilmUseCases {
Film getFilm(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.

Primary port implementation

FilmUseCasesImpl is the implementation of that contract. For now, it returns a hardcoded film regardless of the ID provided.

java/dev/pollito/spring_java/sakila/film/domain/service/FilmUseCasesImpl.java
package dev.pollito.spring_java.sakila.film.domain.service;

import dev.pollito.spring_java.sakila.film.domain.model.Film;
import dev.pollito.spring_java.sakila.film.domain.port.in.FilmUseCases;
import org.springframework.stereotype.Service;

@Service
public class FilmUseCasesImpl implements FilmUseCases {
@Override
public Film getFilm(Integer id) {
return Film.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();
}
}

The hardcoded data is intentional. It lets you verify that the full request-response cycle works end-to-end before introducing any persistence complexity.

REST adapter

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.

Response DTO

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 of the API contract.

java/dev/pollito/spring_java/sakila/film/adapter/in/rest/dto/FilmResponse.java
package dev.pollito.spring_java.sakila.film.adapter.in.rest.dto;

import static lombok.AccessLevel.*;

import lombok.*;
import lombok.experimental.FieldDefaults;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@FieldDefaults(level = PRIVATE)
public class FilmResponse {
Integer id;
String title;
String description;
Integer releaseYear;
String rating;
Integer length;
String language;
}

Mapper

FilmRestMapper maps a Film domain model into a FilmResponse DTO. This translation responsibility belongs here, not in the controller or in the domain.

java/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestMapper.java
package dev.pollito.spring_java.sakila.film.adapter.in.rest;

import static java.util.Objects.isNull;

import dev.pollito.spring_java.sakila.film.adapter.in.rest.dto.FilmResponse;
import dev.pollito.spring_java.sakila.film.domain.model.Film;
import org.springframework.stereotype.Component;

@Component
public class FilmRestMapper {
public FilmResponse map(Film source) {
if (isNull(source)) {
return null;
}
return FilmResponse.builder()
.id(source.getId())
.title(source.getTitle())
.description(source.getDescription())
.releaseYear(source.getReleaseYear())
.rating(source.getRating())
.length(source.getLength())
.language(source.getLanguage())
.build();
}
}

Controller

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.

java/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestController.java
package dev.pollito.spring_java.sakila.film.adapter.in.rest;

import dev.pollito.spring_java.sakila.film.adapter.in.rest.dto.FilmResponse;
import dev.pollito.spring_java.sakila.film.domain.port.in.FilmUseCases;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/films")
@RequiredArgsConstructor
public class FilmRestController {
private final FilmUseCases useCases;
private final FilmRestMapper mapper;

@GetMapping("/{id}")
public FilmResponse getFilm(@PathVariable Integer id) {
return mapper.map(useCases.getFilm(id));
}
}

The controller depends only on FilmUseCases (the interface) and FilmRestMapper. It has no knowledge of how FilmUseCases is implemented. Today it's hardcoded data; later it will be a database query. The controller won't change either way.

The complete flow

Here's the full request lifecycle:

Scroll to zoom • Drag corner to resize

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"
}