Previous slide Next slide Toggle fullscreen Toggle overview view Open presenter view
¿Quien soy?
Programo en Java desde Java 1.1.
Actualmente trabajo en https://clarity.ai como backender.
Me encontraréis en:
Agenda
¿Que es FCIS?
Breve resumen novedades del lenguaje java
Ejemplos de código
Antes de empezar
¿Quien ha utilizado o conoce?
Ports and Adapters (Arquitectura Hexagonal)
Domain Driven Design (DDD)
Clean Architecture
Functional Core Imperative Shell (FCIS)
Ports And Adapters
Alistair Cockburn, 2005
Arquitectura Hexagonal
Functional Core Imperative Shell (I)
Functional Core Imperative Shell (II)
Tienen el mismo objetivo:
reducir acoplamiento.
maximizar cohesión.
Aislar el dominio de dependencias externas.
Functional Core Imperative Shell (III)
Todos son más o menos lo mismo.
Hexagonal incide en aislar el código dominio.
FCIS se centra en definir como se debe comportar este.
Functional Core (I)
Es donde reside:
Lógica de negocio.
Toma decisiones.
Functional Core (II)
Inmutable.
Sin side effects.
Sin exceptions.
Sin dependencias.
Functional Core (III)
Funciones puras Determinista
Tests más sencillos y rápidos
Sin necesidad de stubs/mocks
Imperative Shell
Orquesta el flujo de trabajo:
Obtiene información del exterior.
Llama al código de core.
Gestiona los side effects.
Devuelve un resultado.
Novedades del lenguaje Java
Desde Java 11 hasta Java 25:
Records.
Sealed interfaces and classes.
Pattern matching.
Switch expressions.
Records
Introducido en Java 16.
Inmutables.
Constructor canónico.
Records (II)
Métodos autogenerados.
public record Movie (String title, int year, int duration) {
}
equals()
hashcode()
toString()
Records (III)
Constructor Canónico
public record Movie (String title, int year, int duration) {
public Movie {
if (title == null || title.isEmpty()) {
throw new IllegalArgumentException ();
}
}
}
Sealed interfaces
Introducido en Java 17.
Jerarquías de clases cerradas.
non-sealed
Sealed interfaces (II)
public sealed interface Shape {
record Square (int side) implements Shape {}
record Rectangle (int weight, int height) implements Shape {}
record Circle (int radius) implements Shape {}
}
Pattern Matching for Switch
Introducido en Java 21.
Deconstructores, nos permiten acceder a los componentes internos.
Exhaustiveness.
Pattern Matching for Switch (II)
var area = switch (this ) {
case Square (var side) -> side * side;
case Rectangle (var weight, var height) -> weight * height;
case Circle (var radius) -> Math.PI * Math.pow(radius, 2 );
};
Pattern Matching for Switch (III)
Null Patterns
var result = switch (obj) {
case null -> "null" ;
case Integer i -> String.format("int %d" , i);
case Long l -> String.format("long %d" , l);
case Double d -> String.format("double %f" , d);
case String s -> String.format("String %s" , s);
default -> obj.toString();
};
Pattern matching for switch (IV)
Unnamed variables and patterns
var result = switch (obj) {
case Integer _ -> "int" ;
case Long _ -> "long" ;
case Double _ -> "double" ;
case String _ -> "string" ;
default -> "other" ;
};
Incluido en Java 22 / Eliminar verbosidad.
Otra pequeña encuesta
¿Has usado alguna vez algún lenguaje funcional?
Aplicación de Ejemplo
Una sencilla aplicación web para gestionar datos sobre películas
Crear películas.
Buscar películas.
Actualizar películas.
Borrar películas.
Aplicación de Ejemplo (II)
Java 25
Spring Boot 4.0
JSpecify + NullAway
Domain (I)
public record Movie (
Integer id,
String title,
int year,
int duration,
String director,
List<String> cast,
int stars,
String category) {
}
Domain (II)
public record Movie (
@Nullable Integer id, // mark object as nullable with jspecify String title,
int year,
int duration,
String director,
List<String> cast,
int stars,
String category) {
}
Domain (III)
Constructor canónico.
public record Movie (...) {
public Movie {
cast = List.copyOf(cast); }
}
Domain (IV)
Hacer que estados inválidos sean irrepresentables.
public record Movie (...) {
public Movie {
if (title.isEmpty()) { throw new IllegalArgumentException ("title cannot be empty" ); }
}
}
Domain (V)
Withers
public record Movie (...) {
public Movie withId (int id) {
return new Movie (
id, title, year, duration, director, cast, stars, category);
}
}
Domain (VI)
Value Objects
public record Movie (
@Nullable Integer id,
String title,
Year year, Duration duration, String director,
Cast cast, Stars stars, String category) {
}
Domain (VII)
Un value object, por ejemplo Year.
public record Year (int value) {
public Year {
if (value < 1900 || value > 2026 ) {
throw new IllegalArgumentException ("invalid year: " + value);
}
}
}
Gestión de errores (I)
Uso de objetos resultado.
public sealed interface CreateMovieResult {
record MovieCreated (int id) implements CreateMovieResult {}
sealed interface ValidationError extends CreateMovieResult {
record DuplicatedMovie (int id) implements ValidationError {}
record InvalidDuration (int duration) implements ValidationError {}
record InvalidYear (int year) implements ValidationError {}
record InvalidStars (int stats) implements ValidationError {}
}
}
Gestión de errores (II)
public class MovieValidator {
public static Optional<CreateMovieResult> validate (Movie movie, Set<String> categories) {
if (movie.title() == null || movie.title().isBlank()) {
return Optional.of(new EmptyTitle ());
}
if (movie.director() == null || movie.director().isBlank()) {
return Optional.of(new EmptyDirector ());
}
if (movie.cast() == null || movie.cast().isBlank()) {
return Optional.of(new EmptyCast ());
}
return Optional.empty();
}
}
Gestión de errores (III)
public interface MovieRepository {
Collection<Movie> findAll () ;
Optional<Movie> findById (int id) ;
CreateMovieResult create (Movie movie) ; void delete (int id) ;
Optional<Integer> findByTitle (String title) ;
}
Gestión de errores (IV)
public class CreateMovie {
public int execute (Movie movie) {
var validate = validate(movie, categoryRepository.findCategories());
if (validate.isPresent()) {
throw new IllegalArgumentException ("invalid movie: " + movie);
}
if (movieRepository.findByTitle(movie.title()).isPresent()) {
throw new IllegalArgumentException ("duplicated movie: " + movie);
}
var result = movieRepository.create(convert(movie));
return switch (result) {
case MovieCreated (var id) -> id;
default -> throw new IllegalStateException ("unexpected result: " + result);
};
}
}
Gestión de errores (IV)
public class CreateMovie {
public CreateMovieResult execute (Movie movie) {
var validate = validate(movie, categoryRepository.findCategories()); if (validate.isPresent()) { throw new IllegalArgumentException ("invalid movie: " + movie); } if (movieRepository.findByTitle(movie.title()).isPresent()) {
throw new IllegalArgumentException ("duplicated movie: " + movie);
}
var result = movieRepository.create(convert(movie));
return switch (result) {
case MovieCreated (var id) -> id;
default -> throw new IllegalStateException ("unexpected result: " + result);
};
}
}
Gestión de errores (IV)
public class CreateMovie {
public CreateMovieResult execute (Movie movie) {
var validate = validate(movie, categoryRepository.findCategories());
if (validate.isPresent()) {
throw new IllegalArgumentException ("invalid movie: " + movie);
}
if (movieRepository.findByTitle(movie.title()).isPresent()) { throw new IllegalArgumentException ("duplicated movie: " + movie); } var result = movieRepository.create(convert(movie));
return switch (result) {
case MovieCreated (var id) -> id;
default -> throw new IllegalStateException ("unexpected result: " + result);
};
}
}
Gestión de errores (IV)
public class CreateMovie {
public CreateMovieResult execute (Movie movie) {
var validate = validate(movie, categoryRepository.findCategories());
if (validate.isPresent()) {
throw new IllegalArgumentException ("invalid movie: " + movie);
}
if (movieRepository.findByTitle(movie.title()).isPresent()) {
throw new IllegalArgumentException ("duplicated movie: " + movie);
}
var result = movieRepository.create(convert(movie)); return switch (result) { case MovieCreated (var id) -> id; default -> throw new IllegalStateException ("unexpected result: " + result); };
}
}
Gestión de errores (V)
public class CreateMovie {
public CreateMovieResult execute (Movie movie) {
return validate(movie, categoryRepository.findCategories()) .or(() -> movieRepository.findByTitle(title).map(DuplicatedMovie::new )) .orElseGet(() -> movieRepository.create(movie)); }
}
Gestión de errores (VI)
Uso de switch expresión en el controlador.
public class MoviesController {
@PostMapping("/movies")
public ResponseEntity<Integer> create (@RequestBody Movie movie) {
var result = createMovie.execute(movie);
return switch (result) {
case MovieCreated (var id) -> ResponseEntity.ok(id); case ValidationError e -> ResponseEntity.of(toProblem(e)).build(); }; } }
Gestión de errores (VII)
static ProblemDetail toProblem (ValidationError error) {
return switch (error) {
case EmptyTitle () -> ProblemDetail.forStatusAndDetail(BAD_REQUEST, "title cannot be empty" );
case EmptyDirector () -> ProblemDetail.forStatusAndDetail(BAD_REQUEST, "director cannot be empty" );
case InvalidDuration (var duration) -> {
var problem = ProblemDetail.forStatusAndDetail(BAD_REQUEST, "invalid duration: " + duration);
problem.setProperty("duration" , duration);
yield problem;
}
case InvalidYear (var year) -> {
var problem = ProblemDetail.forStatusAndDetail(BAD_REQUEST, "invalid year: " + year);
problem.setProperty("year" , year);
yield problem;
}
};
}
Gestión de errores (VIII)
Uso del tipo result Result.
public sealed interface Result <F, S> {
record Failure <F, S>(F failure) implements Result <F, S> {}
record Success <F, S>(S success) implements Result <F, S> {}
default <T> T fold (Function<F, T> onFailure, Function<S, T> onSuccess) {
return switch (this ) {
case Failure<F, S>(F failure) -> onFailure.apply(failure);
case Success<F, S>(S success) -> onSuccess.apply(success);
};
}
}
Gestión de errores (IX)
public class MovieValidator {
public static Result<MovieError, Movie> validate (Movie movie, Set<String> categories) {
if (movie.title() == null || movie.title().isBlank()) {
return Result.failure(new EmptyTitle ());
}
if (movie.director() == null || movie.director().isBlank()) {
return Result.failure(new EmptyDirector ());
}
if (movie.cast() == null || movie.cast().isEmpty()) {
return Result.failure(new EmptyCast ());
}
return Result.success(movie);
}
}
Gestión de errores (X)
Podemos usar Result para combinar diferentes operaciones.
public class CreateMovie {
public Result<MovieError, Integer> execute (Movie movie) {
return validate(movie, categoryRepository.findCategories())
.flatMap(this ::checkDuplicatedMovie)
.flatMap(movieRepository::create);
}
}
Gestión de errores (XI)
public class MoviesController {
@PostMapping("/movies")
public ResponseEntity<Integer> create (@RequestBody Movie movie) {
var result = create.execute(movie);
return result.fold(
error -> ResponseEntity.of(toProblem(error)).build(),
ResponseEntity::ok);
}
}
Uso de DSLs (I)
Podemos subir otro peldaño más y usar DSLs.
@Diesel(errorType = MovieError.class)
public interface MovieRepository {
Collection<Movie> findAll () ;
Result<MovieError, Movie> findById (int id) ;
Result<MovieError, Integer> create (Movie movie) ;
void delete (int id) ;
Result<MovieError, Integer> findByTitle (String title) ;
}
Uso de DSLs (II)
Genera un código como a este:
public interface MovieRepositoryDsl {
static <S extends MovieRepository , E extends MovieError > Program<S, E, Collection<Movie>> findAll () {
return Program.effect(state -> state.findAll());
}
static <S extends MovieRepository , E extends MovieError > Program<S, E, Integer> create (Movie movie) {
return Program.effectR(state -> state.create(movie).mapError(e -> (E) e));
}
}
Uso de DSLs (III)
Y podemos usarla en el caso de uso:
public class CreateMovie {
public static <S extends MovieRepository & CategoryRepository> Program<S, MovieError, Integer> execute (Movie movie) {
return pipe(
findCategories(),
categories -> validate(movie, categories),
CreateMovie::checkDuplicatedMovie,
MovieRepositoryDsl::create);
}
}
Uso de DSLs (IV)
En el controlador
public class MoviesController {
@PostMapping("/movies")
public ResponseEntity<Integer> create (@RequestBody Movie movie) {
var result = CreateMovie.execute(movie).eval(new Context (movieRepository, categoryRepository));
return result.fold(
error -> ResponseEntity.of(toProblem(error)).build(),
ResponseEntity::ok);
}
}
Conclusiones
La programación funcional en Java es posible (hasta cierto punto)
Aplicar las restricciones de la programación funcional pueden mejorar la calidad del software.
Inmutabilidad
Funciones puras sin side effects
FCIS ayuda a definir bien los limites entre qué es core y qué no lo es.
¿Preguntas?
¡Gracias!
Links
incluso durante la deserialización.
en un POJO normal, al deserializar el objeto no se llama al constructor nunca,
los atributos del objeto se rellenan desde fuera de manera insegura
si tuviéramos una validación en el constructor se la saltaría
antes no era posible usarlo
La programación funcional puede parecer que te chocas con una pared
pero es algo más parecido a una escalera.
Subes peldaños hasta donde te encuentras más cómodo.
Modelamos Movie como un record que es inmutable.
Recomiendo usar jspecify + error_prone + null-away.
Luego compartiré el proyecto con el ejemplo.
El uso de @NullMarked paquete por paquete puede ser
muy tedioso.
Se puede configurar null-away para que a partir de un
cierto paquete hacia abajo se considere como @NullMarked
Nos podemos ahorrar un montón de ficheros package-info.java
Hay que tener cuidado con los records y los colecciones
En java las colecciones por defecto son mutables.
Y un record tiene que se inmutable.
Como he dicho antes el constructor canónico se llama siempre.
El constructor canónico siempre se llama.
No es necesario comprobar si title es nulo, ya que null-away
nos asegura de que no puede ser nunca nulo.
Esto sería en tiempo de ejecución, pero estaría genial poder
este tipo de cosas en tipo de compilación.
Los records son inmutables por lo que si necesitamos cambiar algo
debemos crear una nueva instancia del objeto.
Tanto objeto diferente puede penalizar el consumo de memoria.
Hay que hacer un trade off si merece la pena tener código más
expresivo o la performance.
aquí uso optional para indicar que si el objeto es válido se
devolverá un optional vacío
Algo importante que nos dice FCIS es que dentro del dominio
considerando MovieValidator dentro de core, no podemos
tener dependencias. Por lo que la llamada al repositorio de
categorías se hace desde el caso de uso que es la parte del
shell.
Otra cosa importante es que si queremos hacer una operación
que requiera el uso del repositorio también es algo que debe
ir en la shell.
Aquí como estamos en la shell esta si que es imperativa.
aprovechando que usamos Optional, podemos simplificar el código
de esta forma.
De alguna manera lo funcional infecta al código que lo usa.
Aunque si no nos sentimos cómodos podemos dejarlo como estaba
antes.
ProblemDetail es un estándar RFC https://www.rfc-editor.org/rfc/rfc9457.html
En spring desde la versión 6.0 y la 3.0 de spring-boot
IMO esto es mucho mejor que los @ExceptionHandler.
Podemos sacar factor común de los objetos result.
Siempre tienen un resultado de tipo success o otro failure
Podemos generalizarlo.
Es muy similar a Either de scala o vavr, por ejemplo.
Result tiene los métodos map y flatMap
De funcionalidad similar a los que tiene Optional o Stream
aquí podemos hacer uso del método fold
con una librería con la que estoy trabajando se puede marcar
cualquier tipo de interfaz. Y esta anotación se procesa y
se genera un objeto MovieRepositoryDsl.
Este código es el pegamento que podemos usar para crear un DSL
Program es declarativo, es realmente una estructura de datos
que define un DSL para componer diferentes operaciones.
Hasta que no se llama al método eval, no se ejecuta nada.
El método pipe es un operador que permite encadenar llamadas,
de manera similar al uso del método flatMap.
Pero he descubierto que usando pipe en lugar de flatMap, el compilador
infiere los tipos muchos mejor.
Encadenando flatMaps el compilador pierde el hilo y termina por
no saber qué está pasando.