In the previous documents we wired JPA entities into the hexagonal architecture and built read-only endpoints. This document adds the remaining write operations: create, update, and delete. By the end, the Film resource is fully CRUD.
No new dependencies are needed. The existing stack (Spring Data JPA, MapStruct / ModelMapper, OpenAPI generator) handles everything.
Files overview
File Tree
├── ...
└── src/
├── main/java/dev/pollito/spring_java/sakila/film/
│ ├── adapter/
│ │ ├── in/rest/
│ │ │ └── FilmRestController.java
│ │ └── out/jpa/
│ │ ├── FilmJpaMapper.java
│ │ ├── FilmRepositoryImpl.java
│ │ └── LanguageJpaRepository.java
│ └── domain/
│ ├── port/
│ │ ├── in/
│ │ │ └── FilmUseCases.java
│ │ └── out/
│ │ └── FilmRepository.java
│ └── service/
│ └── FilmUseCasesImpl.java
└── test/java/dev/pollito/spring_java/sakila/film/
├── adapter/
│ ├── in/rest/
│ │ └── FilmRestControllerMockMvcTest.java
│ └── out/jpa/
│ └── FilmRepositoryImplDataJpaTest.java
└── domain/
└── service/
└── FilmUseCasesImplTest.java
Expand(22 more lines) File Tree
├── ...
└── src/
├── main/kotlin/dev/pollito/spring_kotlin/sakila/film/
│ ├── adapter/
│ │ ├── in/rest/
│ │ │ └── FilmRestController.kt
│ │ └── out/jpa/
│ │ ├── FilmJpaMapper.kt
│ │ ├── FilmRepositoryImpl.kt
│ │ └── LanguageJpaRepository.kt
│ └── domain/
│ ├── port/
│ │ ├── in/
│ │ │ └── FilmUseCases.kt
│ │ └── out/
│ │ └── FilmRepository.kt
│ └── service/
│ └── FilmUseCasesImpl.kt
└── test/kotlin/dev/pollito/spring_kotlin/sakila/film/
├── adapter/
│ ├── in/rest/
│ │ └── FilmRestControllerMockMvcTest.kt
│ └── out/jpa/
│ └── FilmRepositoryImplDataJpaTest.kt
└── domain/
└── service/
└── FilmUseCasesImplTest.kt
Expand(22 more lines) File Tree
├── ...
└── src/
├── main/groovy/dev/pollito/spring_groovy/sakila/film/
│ ├── adapter/
│ │ ├── in/rest/
│ │ │ └── FilmRestController.groovy
│ │ └── out/jpa/
│ │ ├── FilmJpaMapper.groovy
│ │ ├── FilmRepositoryImpl.groovy
│ │ └── LanguageJpaRepository.groovy
│ └── domain/
│ ├── port/
│ │ ├── in/
│ │ │ └── FilmUseCases.groovy
│ │ └── out/
│ │ └── FilmRepository.groovy
│ └── service/
│ └── FilmUseCasesImpl.groovy
└── test/groovy/dev/pollito/spring_groovy/sakila/film/
├── adapter/
│ ├── in/rest/
│ │ └── FilmRestControllerMockMvcSpec.groovy
│ └── out/jpa/
│ └── FilmRepositoryImplDataJpaSpec.groovy
└── domain/
└── service/
└── FilmUseCasesImplSpec.groovy
Expand(22 more lines)
The changes span all three layers of the hexagonal architecture: the inbound REST adapter, the domain ports and service, and the outbound JPA adapter. Tests are updated in parallel to cover the new behavior.
Define the domain ports
Start with the contracts. Both the primary port (FilmUseCases) and the secondary port (FilmRepository) grow new method signatures for createFilm, updateFilm, and deleteFilm.
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 ;
import org . springframework . data . domain . Page ;
import org . springframework . data . domain . Pageable ;
public interface FilmUseCases {
Film createFilm ( Film film ) ;
Film getFilm ( Integer id ) ;
Page < Film > getFilms ( Pageable pageable ) ;
Film updateFilm ( Integer id , Film film ) ;
void deleteFilm ( Integer id ) ;
}
Expand(3 more lines) kotlin/dev/pollito/spring_kotlin/sakila/film/domain/port/in/FilmUseCases.kt
package dev . pollito . spring_kotlin . sakila . film . domain . port . ` in `
import dev . pollito . spring_kotlin . sakila . film . domain . model . Film
import org . springframework . data . domain . Page
import org . springframework . data . domain . Pageable
interface FilmUseCases {
fun createFilm ( film : Film ) : Film
fun getFilm ( id : Int ) : Film
fun getFilms ( pageable : Pageable ) : Page < Film >
fun updateFilm ( id : Int , film : Film ) : Film
fun deleteFilm ( id : Int )
}
Expand(3 more lines) groovy/dev/pollito/spring_groovy/sakila/film/domain/port/in/FilmUseCases.groovy
package dev . pollito . spring_groovy . sakila . film . domain . port . in
import dev . pollito . spring_groovy . sakila . film . domain . model . Film
import org . springframework . data . domain . Page
import org . springframework . data . domain . Pageable
interface FilmUseCases {
Film createFilm ( Film film )
Film getFilm ( Integer id )
Page < Film > getFilms ( Pageable pageable )
Film updateFilm ( Integer id , Film film )
void deleteFilm ( Integer id )
}
Expand(3 more lines)
java/dev/pollito/spring_java/sakila/film/domain/port/out/FilmRepository.java
package dev . pollito . spring_java . sakila . film . domain . port . out ;
import dev . pollito . spring_java . sakila . film . domain . model . Film ;
import org . springframework . data . domain . Page ;
import org . springframework . data . domain . Pageable ;
public interface FilmRepository {
Film createFilm ( Film film ) ;
Film getFilm ( Integer id ) ;
Page < Film > getFilms ( Pageable pageable ) ;
Film updateFilm ( Integer id , Film film ) ;
void deleteFilm ( Integer id ) ;
}
Expand(3 more lines) kotlin/dev/pollito/spring_kotlin/sakila/film/domain/port/out/FilmRepository.kt
package dev . pollito . spring_kotlin . sakila . film . domain . port . out
import dev . pollito . spring_kotlin . sakila . film . domain . model . Film
import org . springframework . data . domain . Page
import org . springframework . data . domain . Pageable
interface FilmRepository {
fun createFilm ( film : Film ) : Film
fun getFilm ( id : Int ) : Film
fun getFilms ( pageable : Pageable ) : Page < Film >
fun updateFilm ( id : Int , film : Film ) : Film
fun deleteFilm ( id : Int )
}
Expand(3 more lines) groovy/dev/pollito/spring_groovy/sakila/film/domain/port/out/FilmRepository.groovy
package dev . pollito . spring_groovy . sakila . film . domain . port . out
import dev . pollito . spring_groovy . sakila . film . domain . model . Film
import org . springframework . data . domain . Page
import org . springframework . data . domain . Pageable
interface FilmRepository {
Film createFilm ( Film film )
Film getFilm ( Integer id )
Page < Film > getFilms ( Pageable pageable )
Film updateFilm ( Integer id , Film film )
void deleteFilm ( Integer id )
}
Expand(3 more lines)
Wire the service
FilmUseCasesImpl delegates to the secondary port. No business rules yet, just pass-through.
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 dev . pollito . spring_java . sakila . film . domain . port . out . FilmRepository ;
import lombok . RequiredArgsConstructor ;
import org . springframework . data . domain . Page ;
import org . springframework . data . domain . Pageable ;
import org . springframework . stereotype . Service ;
@Service
@RequiredArgsConstructor
public class FilmUseCasesImpl implements FilmUseCases {
private final FilmRepository repository ;
@Override
public Film createFilm ( Film film ) {
return repository . createFilm ( film ) ;
}
@Override
public Film getFilm ( Integer id ) {
return repository . getFilm ( id ) ;
}
@Override
public Page < Film > getFilms ( Pageable pageable ) {
return repository . getFilms ( pageable ) ;
}
@Override
public Film updateFilm ( Integer id , Film film ) {
return repository . updateFilm ( id , film ) ;
}
@Override
public void deleteFilm ( Integer id ) {
repository . deleteFilm ( id ) ;
}
}
Expand(26 more lines) kotlin/dev/pollito/spring_kotlin/sakila/film/domain/service/FilmUseCasesImpl.kt
package dev . pollito . spring_kotlin . sakila . film . domain . service
import dev . pollito . spring_kotlin . sakila . film . domain . model . Film
import dev . pollito . spring_kotlin . sakila . film . domain . port . ` in ` . FilmUseCases
import dev . pollito . spring_kotlin . sakila . film . domain . port . out . FilmRepository
import org . springframework . data . domain . Page
import org . springframework . data . domain . Pageable
import org . springframework . stereotype . Service
@Service
class FilmUseCasesImpl ( private val repository : FilmRepository ) : FilmUseCases {
override fun createFilm ( film : Film ) : Film = repository . createFilm ( film )
override fun getFilm ( id : Int ) : Film = repository . getFilm ( id )
override fun getFilms ( pageable : Pageable ) : Page < Film > = repository . getFilms ( pageable )
override fun updateFilm ( id : Int , film : Film ) : Film = repository . updateFilm ( id , film )
override fun deleteFilm ( id : Int ) = repository . deleteFilm ( id )
}
Expand(7 more lines) groovy/dev/pollito/spring_groovy/sakila/film/domain/service/FilmUseCasesImpl.groovy
package dev . pollito . spring_groovy . sakila . film . domain . service
import dev . pollito . spring_groovy . sakila . film . domain . model . Film
import dev . pollito . spring_groovy . sakila . film . domain . port . in . FilmUseCases
import dev . pollito . spring_groovy . sakila . film . domain . port . out . FilmRepository
import groovy . transform . CompileStatic
import org . springframework . data . domain . Page
import org . springframework . data . domain . Pageable
import org . springframework . stereotype . Service
@Service
@CompileStatic
class FilmUseCasesImpl implements FilmUseCases {
private final FilmRepository repository
FilmUseCasesImpl ( FilmRepository repository ) {
this . repository = repository
}
@Override
Film createFilm ( Film film ) {
repository . createFilm ( film )
}
@Override
Film getFilm ( Integer id ) {
repository . getFilm ( id )
}
@Override
Page < Film > getFilms ( Pageable pageable ) {
repository . getFilms ( pageable )
}
@Override
Film updateFilm ( Integer id , Film film ) {
repository . updateFilm ( id , film )
}
@Override
void deleteFilm ( Integer id ) {
repository . deleteFilm ( id )
}
}
Expand(30 more lines)
Implement the repository
The repository implementation looks up language entities, maps the domain model to a JPA entity, saves it, and maps the result back.
Creating a film
java/dev/pollito/spring_java/sakila/film/adapter/out/jpa/FilmRepositoryImpl.java
@Override
public Film createFilm ( @NonNull Film film ) {
var language = languageJpaRepository . findByName ( film . getLanguage ( ) . getValue ( ) ) . orElseThrow ( ) ;
var originalLanguage =
film . getOriginalLanguage ( ) != null
? languageJpaRepository . findByName ( film . getOriginalLanguage ( ) . getValue ( ) ) . orElseThrow ( )
: null ;
var entity = mapper . map ( film , language , originalLanguage ) ;
entity . setLastUpdate ( java . time . LocalDateTime . now ( ) ) ;
return mapper . map ( repository . save ( entity ) ) ;
}
kotlin/dev/pollito/spring_kotlin/sakila/film/adapter/out/jpa/FilmRepositoryImpl.kt
override fun createFilm ( film : Film ) : Film {
val language =
languageJpaRepository . findByName ( film . language . getValue ( ) ) ?: throw NoSuchElementException ( )
val originalLanguage =
film . originalLanguage ? . let {
languageJpaRepository . findByName ( it . getValue ( ) ) ?: throw NoSuchElementException ( )
}
val entity = mapper . map ( film , language , originalLanguage )
entity . lastUpdate = now ( )
return mapper . map ( repository . save ( entity ) )
}
groovy/dev/pollito/spring_groovy/sakila/film/adapter/out/jpa/FilmRepositoryImpl.groovy
@Override
Film createFilm ( Film film ) {
def language = languageJpaRepository . findByName ( film . language . value ) . orElseThrow ( )
def originalLanguage = film . originalLanguage ? languageJpaRepository . findByName ( film . originalLanguage . value ) . orElseThrow ( ) : null
def entity = mapper . map ( film , language , originalLanguage )
entity . lastUpdate = LocalDateTime . now ( )
mapper . map ( repository . save ( entity ) )
}
Notice the language lookup. FilmFields has "English" as a string, but the database stores languages as a separate table. The repository queries LanguageJpaRepository by name, maps the domain model to the JPA entity using the resolved language references, and sets lastUpdate before saving.
Updating a film
Update follows the same pattern as create, except the repository sets the film ID explicitly before saving so JPA treats it as an existing row.
java/dev/pollito/spring_java/sakila/film/adapter/out/jpa/FilmRepositoryImpl.java
@Override
public Film updateFilm ( Integer id , @NonNull Film film ) {
var language = languageJpaRepository . findByName ( film . getLanguage ( ) . getValue ( ) ) . orElseThrow ( ) ;
var originalLanguage =
film . getOriginalLanguage ( ) != null
? languageJpaRepository . findByName ( film . getOriginalLanguage ( ) . getValue ( ) ) . orElseThrow ( )
: null ;
var entity = mapper . map ( film , language , originalLanguage ) ;
entity . setFilmId ( id ) ;
entity . setLastUpdate ( java . time . LocalDateTime . now ( ) ) ;
return mapper . map ( repository . save ( entity ) ) ;
}
}
kotlin/dev/pollito/spring_kotlin/sakila/film/adapter/out/jpa/FilmRepositoryImpl.kt
override fun updateFilm ( id : Int , film : Film ) : Film {
val language =
languageJpaRepository . findByName ( film . language . getValue ( ) ) ?: throw NoSuchElementException ( )
val originalLanguage =
film . originalLanguage ? . let {
languageJpaRepository . findByName ( it . getValue ( ) ) ?: throw NoSuchElementException ( )
}
val entity = mapper . map ( film , language , originalLanguage )
entity . filmId = id
entity . lastUpdate = now ( )
return mapper . map ( repository . save ( entity ) )
}
}
groovy/dev/pollito/spring_groovy/sakila/film/adapter/out/jpa/FilmRepositoryImpl.groovy
@Override
Film updateFilm ( Integer id , Film film ) {
def language = languageJpaRepository . findByName ( film . language . value ) . orElseThrow ( )
def originalLanguage = film . originalLanguage ? languageJpaRepository . findByName ( film . originalLanguage . value ) . orElseThrow ( ) : null
def entity = mapper . map ( film , language , originalLanguage )
entity . filmId = id
entity . lastUpdate = LocalDateTime . now ( )
mapper . map ( repository . save ( entity ) )
}
}
The critical difference from create: entity.setFilmId(id) (or entity.filmId = id). Without this, JPA would insert a new row instead of updating the existing one.
Deleting a film
Delete is simpler. The repository checks that the film exists, then removes it. If the ID doesn't exist, findById(...).orElseThrow() raises NoSuchElementException, which the global ControllerAdvice already maps to 404 NOT_FOUND.
java/dev/pollito/spring_java/sakila/film/adapter/out/jpa/FilmRepositoryImpl.java
@Override
public void deleteFilm ( Integer id ) {
repository . findById ( id ) . orElseThrow ( ) ;
repository . deleteById ( id ) ;
}
kotlin/dev/pollito/spring_kotlin/sakila/film/adapter/out/jpa/FilmRepositoryImpl.kt
override fun deleteFilm ( id : Int ) {
repository . findById ( id ) . orElseThrow ( )
repository . deleteById ( id )
}
groovy/dev/pollito/spring_groovy/sakila/film/adapter/out/jpa/FilmRepositoryImpl.groovy
@Override
void deleteFilm ( Integer id ) {
repository . findById ( id ) . orElseThrow ( )
repository . deleteById ( id )
}
Expose the endpoint
The REST controller maps the incoming DTO to a domain model, calls the use case, and wraps the result in the appropriate HTTP response.
Creating a film
The controller returns 201 CREATED with the newly created film in the body.
java/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestController.java
@Override
public ResponseEntity < FilmResponse > createFilm ( FilmFields filmFields ) {
return status ( CREATED )
. body (
new FilmResponse ( )
. data ( mapper . map ( useCases . createFilm ( mapper . map ( filmFields ) ) ) )
. instance ( request . getRequestURI ( ) )
. timestamp ( now ( ) )
. trace ( current ( ) . getSpanContext ( ) . getTraceId ( ) )
. status ( CREATED . value ( ) ) ) ;
}
kotlin/dev/pollito/spring_kotlin/sakila/film/adapter/in/rest/FilmRestController.kt
override fun createFilm ( filmFields : FilmFields ) : ResponseEntity < FilmResponse > {
return status ( CREATED )
. body (
FilmResponse (
data = mapper . map ( useCases . createFilm ( mapper . map ( filmFields ) ) ) ,
instance = request . requestURI ,
timestamp = now ( ) ,
trace = current ( ) . spanContext . traceId ,
status = CREATED . value ( ) ,
)
)
}
groovy/dev/pollito/spring_groovy/sakila/film/adapter/in/rest/FilmRestController.groovy
@Override
ResponseEntity < FilmResponse > createFilm ( FilmFields filmFields ) {
status ( CREATED )
. body (
new FilmResponse ( )
. data ( mapper . map ( useCases . createFilm ( mapper . map ( filmFields ) ) ) )
. instance ( request . requestURI )
. timestamp ( now ( ) )
. trace ( Span . current ( ) . spanContext . traceId )
. status ( CREATED . value ( ) )
)
}
Updating a film
java/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestController.java
@Override
public ResponseEntity < FilmResponse > updateFilm ( Integer id , FilmFields filmFields ) {
return ok (
new FilmResponse ( )
. data ( mapper . map ( useCases . updateFilm ( id , mapper . map ( filmFields ) ) ) )
. instance ( request . getRequestURI ( ) )
. timestamp ( now ( ) )
. trace ( current ( ) . getSpanContext ( ) . getTraceId ( ) )
. status ( OK . value ( ) ) ) ;
}
kotlin/dev/pollito/spring_kotlin/sakila/film/adapter/in/rest/FilmRestController.kt
override fun updateFilm ( id : Int , filmFields : FilmFields ) : ResponseEntity < FilmResponse > {
return ok (
FilmResponse (
data = mapper . map ( useCases . updateFilm ( id , mapper . map ( filmFields ) ) ) ,
instance = request . requestURI ,
timestamp = now ( ) ,
trace = current ( ) . spanContext . traceId ,
status = OK . value ( ) ,
)
)
}
groovy/dev/pollito/spring_groovy/sakila/film/adapter/in/rest/FilmRestController.groovy
@Override
ResponseEntity < FilmResponse > updateFilm ( Integer id , FilmFields filmFields ) {
ok (
new FilmResponse ( )
. data ( mapper . map ( useCases . updateFilm ( id , mapper . map ( filmFields ) ) ) )
. instance ( request . requestURI )
. timestamp ( now ( ) )
. trace ( Span . current ( ) . spanContext . traceId )
. status ( OK . value ( ) )
)
}
Deleting a film
The controller returns 204 NO_CONTENT on success. No body, just the status. And if the film doesn't exist, the findById check throws before the delete happens, so you never accidentally delete the wrong thing or silently succeed.
java/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestController.java
@Override
public ResponseEntity < Void > deleteFilm ( Integer id ) {
useCases . deleteFilm ( id ) ;
return ResponseEntity . noContent ( ) . build ( ) ;
}
kotlin/dev/pollito/spring_kotlin/sakila/film/adapter/in/rest/FilmRestController.kt
override fun deleteFilm ( id : Int ) : ResponseEntity < Unit > {
useCases . deleteFilm ( id )
return ResponseEntity . noContent ( ) . build ( )
}
groovy/dev/pollito/spring_groovy/sakila/film/adapter/in/rest/FilmRestController.groovy
@Override
ResponseEntity < Void > deleteFilm ( Integer id ) {
useCases . deleteFilm ( id )
ResponseEntity . noContent ( ) . build ( )
}
Testing the new operations
Each commit adds tests at three levels:
MockMvc tests verify the REST adapter: correct HTTP status, response body shape, and error handling.
Data JPA tests verify the repository implementation against a real database.
For create and update, the Data JPA tests assert that the returned domain model has the expected title and ID.
For delete, they assert that the entity is removed and that calling getFilm afterward throws NoSuchElementException.
Not-found case for delete: passing a non-existent ID triggers the same NoSuchElementException path that the global ControllerAdvice turns into a 404.
Unit tests verify the domain service in isolation with mocked repositories.
Try it
With all operations in place, you can exercise the full lifecycle:
Terminal
# Create a new film
curl -s -X POST https://sakila-java.pollito.tech/api/films \
-H "Content-Type: application/json" \
-d '{"title":"NEW FILM","language":"English","rentalDuration":3,"rentalRate":4.99,"replacementCost":20.99}' | jq
{
"instance": "/api/films",
"status": 201,
"timestamp": "2026-04-29T11:05:41.803702787Z",
"trace": "65c2da37361f6d669884f7caf6ed2a93",
"data": {
"title": "NEW FILM",
"language": "English",
"rentalDuration": 3,
"rentalRate": 4.99,
"replacementCost": 20.99,
"id": 1001,
"lastUpdate": "2026-04-29T11:05:41.738848092Z",
"description": null,
"length": null,
"originalLanguage": null,
"rating": null,
"releaseYear": null,
"specialFeatures": null
}
}
# Update an existing film
curl -s -X PUT https://sakila-java.pollito.tech/api/films/1001 \
-H "Content-Type: application/json" \
-d '{"title":"UPDATED FILM","language":"English","rentalDuration":3,"rentalRate":4.99,"replacementCost":20.99}' | jq
{
"instance": "/api/films/1001",
"status": 200,
"timestamp": "2026-04-29T11:39:44.242877421Z",
"trace": "1ff671817013fd4896e987c81994b685",
"data": {
"title": "UPDATED FILM",
"language": "English",
"rentalDuration": 3,
"rentalRate": 4.99,
"replacementCost": 20.99,
"id": 1001,
"lastUpdate": "2026-04-29T11:39:44.236617507Z",
"description": null,
"length": null,
"originalLanguage": null,
"rating": null,
"releaseYear": null,
"specialFeatures": null
}
}
# Delete a film
curl -s -X DELETE https://sakila-java.pollito.tech/api/films/1001 -w "\n%{http_code}\n"
204
Expand(47 more lines)
You now have a complete CRUD resource. The hexagonal boundaries stayed intact: the domain only knows about ports, the REST adapter only knows about DTOs and use cases, and the JPA adapter handles the messy work of mapping, language lookups, and entity state.