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

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
│ │ │ │ ├── FilmRestMapper.java
│ │ │ │ └── FilmRestController.java
│ │ │ └── domain
│ │ │ ├── model
│ │ │ │ └── Film.java
│ │ │ └── port
│ │ │ └── in
│ │ │ ├── FindByIdPortIn.java
│ │ │ └── FindByIdPortInImpl.java
│ │ └── ...
│ └── resources
│ └── ...
└── test
└── ...

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 {
// ...
id 'com.diffplug.spotless' version '8.1.0'
}
// ...
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 (FindByIdPortIn) 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/FindByIdPortIn.java
package dev.pollito.spring_java.sakila.film.domain.port.in;

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

public interface FindByIdPortIn {
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.

Primary Port Implementation

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.

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

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

@Service
public class FindByIdPortInImpl implements FindByIdPortIn {
@Override
public Film findById(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 from 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 converts 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 convert(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.FindByIdPortIn;
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 FindByIdPortIn findByIdPortIn;
private final FilmRestMapper mapper;

@GetMapping("/{id}")
public FilmResponse findById(@PathVariable Integer id) {
return mapper.convert(findByIdPortIn.findById(id));
}
}

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.

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

What's Next

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.