Un ejemplo de arquitectura hexagonal en Java

Se preparó una traducción del artículo específicamente para estudiantes del curso Java Developer .





Como desarrolladores, a menudo tenemos que lidiar con el código heredado, que es difícil de mantener. Sabes lo difícil que es comprender la lógica simple en un código de espagueti grande y complicado. Mejorar el código o desarrollar nuevas funciones se convierte en una pesadilla para el desarrollador.

Uno de los objetivos principales del diseño de software es la facilidad de mantenimiento. El código mal mantenido se vuelve difícil de administrar. No solo es difícil de escalar, sino que se está convirtiendo en un problema para atraer nuevos desarrolladores.

En el mundo de TI, las cosas se mueven rápido. Si se le pide que implemente urgentemente una nueva funcionalidad o si desea cambiar de una base de datos relacional a NoSQL, ¿cuál será su primera reacción?



Una buena cobertura de prueba aumenta la confianza de los desarrolladores de que no habrá problemas con la nueva versión. Sin embargo, si la lógica de su negocio está entrelazada con la lógica de la infraestructura, puede haber problemas con sus pruebas.


¿Por qué yo?

Pero hay suficiente charla vacía, veamos la arquitectura hexagonal. El uso de esta plantilla lo ayudará a mejorar la capacidad de mantenimiento, la capacidad de prueba y otros beneficios.

Introducción a la arquitectura hexagonal


El término Arquitectura Hexagonal (arquitectura hexagonal, hexagonal) fue acuñado en 2006 por Alistair Cockburn. Este estilo arquitectónico también se conoce como Arquitectura de Puertos y Adaptadores . En palabras simples, los componentes de su aplicación interactúan a través de muchos puntos finales (puertos). Para procesar solicitudes, debe tener adaptadores que coincidan con los puertos.

Aquí puede hacer una analogía con los puertos USB de la computadora. Puede usarlos si tiene un adaptador compatible (cargador o unidad flash).



Esta arquitectura puede representarse esquemáticamente como un hexágono con lógica de negocios en el centro (en el núcleo), rodeada por los objetos con los que interactúa y los componentes que lo controlan, proporcionando datos de entrada.

En la vida real, los usuarios, las llamadas a la API, los scripts automatizados y las pruebas unitarias interactúan y brindan información a su aplicación. Si su lógica de negocios se combina con la lógica de la interfaz de usuario, entonces encontrará muchos problemas. Por ejemplo, será difícil cambiar la entrada de datos desde la interfaz de usuario a pruebas unitarias.

La aplicación también interactúa con objetos externos como bases de datos, colas de mensajes, servidores web (a través de llamadas API HTTP), etc. Si es necesario, migre la base de datos o cargue datos en un archivo, debería poder hacerlo sin afectar el negocio. lógica.

Como su nombre lo indica, " puertos y adaptadores ", hay "puertos" a través de los cuales se produce la interacción y los "adaptadores" son componentes que procesan la entrada del usuario y la convierten al "idioma" del dominio. Los adaptadores encapsulan la lógica de interacción con sistemas externos, como bases de datos, colas de mensajes, etc., y facilitan la comunicación entre la lógica empresarial y los objetos externos.

El siguiente diagrama muestra las capas en las que se divide la aplicación.



La arquitectura hexagonal distingue tres capas en la aplicación: dominio, aplicación e infraestructura:

  • Dominio . La capa contiene la lógica empresarial central. No necesita conocer los detalles de la implementación de las capas externas.
  • Aplicación . Una capa actúa como un puente entre las capas de un dominio y la infraestructura.
  • Infraestructura . La implementación de la interacción del dominio con el mundo exterior. Las capas internas le parecen una caja negra.

Según esta arquitectura, dos tipos de participantes interactúan con la aplicación: primaria (controlador) y secundaria (controlada). Los actores clave envían solicitudes y administran la aplicación (por ejemplo, usuarios o pruebas automatizadas). Los secundarios proporcionan la infraestructura para la comunicación con el mundo exterior (estos son adaptadores de bases de datos, clientes TCP o HTTP).
Esto se puede representar de la siguiente manera: el



lado izquierdo del hexágono consta de componentes que proporcionan información para el dominio ("controlan" la aplicación) y el lado derecho consta de componentes controlados por nuestra aplicación.

Ejemplo


Diseñemos una aplicación que almacenará críticas de películas. El usuario debe poder enviar una solicitud con el nombre de la película y obtener cinco reseñas aleatorias.

Para simplificar, crearemos una aplicación de consola con almacenamiento de datos en RAM. La respuesta al usuario se mostrará en la consola.

Tenemos un usuario (Usuario) que envía una solicitud a la aplicación. Por lo tanto, el usuario se convierte en un "administrador" (controlador). La aplicación debería poder recibir datos de cualquier tipo de almacenamiento y enviar los resultados a la consola o a un archivo. Los objetos gestionados (controlados) serán el "almacén de datos" ( IFetchMovieReviews) y la "impresora de respuesta" ( IPrintMovieReviews).

La siguiente figura muestra los componentes principales de nuestra aplicación.



A la izquierda están los componentes que proporcionan la entrada de datos en la aplicación. A la derecha están los componentes que le permiten interactuar con la base de datos y la consola.

Veamos el código de la aplicación.

Puerto de control

public interface IUserInput {
    public void handleUserInput(Object userCommand);
}

Puertos gestionados

public interface IFetchMovieReviews {
    public List<MovieReview> fetchMovieReviews(MovieSearchRequest movieSearchRequest);
}

public interface IPrintMovieReviews {
    public void writeMovieReviews(List<MovieReview> movieReviewList);
}

Adaptadores de puerto gestionados Las

películas se obtendrán del repositorio de películas (MovieReviewsRepo). Mostrar críticas de películas en la consola será una clase ConsolePrinter. Implementemos las dos interfaces anteriores.

public class ConsolePrinter implements IPrintMovieReviews {
    @Override
    public void writeMovieReviews(List<MovieReview> movieReviewList) {
        movieReviewList.forEach(movieReview -> {
            System.out.println(movieReview.toString());
        });
    }
}

public class MovieReviewsRepo implements IFetchMovieReviews {
    private Map<String, List<MovieReview>> movieReviewMap;

    public MovieReviewsRepo() {
        initialize();
    }

    public List<MovieReview> fetchMovieReviews(MovieSearchRequest movieSearchRequest) {

        return Optional.ofNullable(movieReviewMap.get(movieSearchRequest.getMovieName()))
            .orElse(new ArrayList<>());
    }

    private void initialize() {
        this.movieReviewMap = new HashMap<>();
        movieReviewMap.put("StarWars", Collections.singletonList(new MovieReview("1", 7.5, "Good")));
        movieReviewMap.put("StarTreck", Arrays.asList(new MovieReview("1", 9.5, "Excellent"), new MovieReview("1", 8.5, "Good")));
    }
}

Dominio


La tarea principal de nuestra aplicación es procesar las solicitudes de los usuarios. Necesita obtener las películas, procesarlas y transferir los resultados a la "impresora". Por el momento, solo tenemos una funcionalidad: la búsqueda de películas. Para procesar las solicitudes de los usuarios, utilizaremos la interfaz estándar Consumer.

Veamos la clase principal MovieApp.

public class MovieApp implements Consumer<MovieSearchRequest> {
    private IFetchMovieReviews fetchMovieReviews;
    private IPrintMovieReviews printMovieReviews;
    private static Random rand = new Random();

    public MovieApp(IFetchMovieReviews fetchMovieReviews, IPrintMovieReviews printMovieReviews) {
        this.fetchMovieReviews = fetchMovieReviews;
        this.printMovieReviews = printMovieReviews;
    }

    private List<MovieReview> filterRandomReviews(List<MovieReview> movieReviewList) {
        List<MovieReview> result = new ArrayList<MovieReview>();
        // logic to return random reviews
        for (int index = 0; index < 5; ++index) {
            if (movieReviewList.size() < 1)
                break;
            int randomIndex = getRandomElement(movieReviewList.size());
            MovieReview movieReview = movieReviewList.get(randomIndex);
            movieReviewList.remove(movieReview);
            result.add(movieReview);
        }
        return result;
    }

    private int getRandomElement(int size) {
        return rand.nextInt(size);
    }

    public void accept(MovieSearchRequest movieSearchRequest) {
        List<MovieReview> movieReviewList = fetchMovieReviews.fetchMovieReviews(movieSearchRequest);
        List<MovieReview> randomReviews = filterRandomReviews(new ArrayList<>(movieReviewList));
        printMovieReviews.writeMovieReviews(randomReviews);
    }
}

Ahora definimos una clase CommandMapperModelque asignará comandos a los controladores.

public class CommandMapperModel {
    private static final Class<MovieSearchRequest> searchMovies = MovieSearchRequest.class;

    public static Model build(Consumer<MovieSearchRequest> displayMovies) {
        Model model = Model.builder()
            .user(searchMovies)
            .system(displayMovies)
            .build();

        return model;
    }
}

Adaptadores de puerto de control


El usuario interactuará con nuestro sistema a través de la interfaz IUserInput. La implementación usará ModelRunnery delegará la ejecución.

public class UserCommandBoundary implements IUserInput {
    private Model model;

    public UserCommandBoundary(IFetchMovieReviews fetchMovieReviews, IPrintMovieReviews printMovieReviews) {
        MovieApp movieApp = new MovieApp(fetchMovieReviews, printMovieReviews);
        model = CommandMapperModel.build(movieApp);
    }

    public void handleUserInput(Object userCommand) {
        new ModelRunner().run(model)
            .reactTo(userCommand);
    }
}

Ahora veamos al usuario que usa la interfaz anterior.

public class MovieUser {
    private IUserInput userInputDriverPort;

    public MovieUser(IUserInput userInputDriverPort) {
        this.userInputDriverPort = userInputDriverPort;
    }

    public void processInput(MovieSearchRequest movieSearchRequest) {
        userInputDriverPort.handleUserInput(movieSearchRequest);
    }
}

solicitud


A continuación, cree una aplicación de consola. Los adaptadores administrados se agregan como dependencias. El usuario creará y enviará una solicitud a la aplicación. La aplicación recibirá datos, procesará y mostrará una respuesta a la consola.

public class Main {

    public static void main(String[] args) {
        IFetchMovieReviews fetchMovieReviews = new MovieReviewsRepo();
        IPrintMovieReviews printMovieReviews = new ConsolePrinter();
        IUserInput userCommandBoundary = new UserCommandBoundary(fetchMovieReviews, printMovieReviews);
        MovieUser movieUser = new MovieUser(userCommandBoundary);
        MovieSearchRequest starWarsRequest = new MovieSearchRequest("StarWars");
        MovieSearchRequest starTreckRequest = new MovieSearchRequest("StarTreck");

        System.out.println("Displaying reviews for movie " + starTreckRequest.getMovieName());
        movieUser.processInput(starTreckRequest);
        System.out.println("Displaying reviews for movie " + starWarsRequest.getMovieName());
        movieUser.processInput(starWarsRequest);
    }

}

Lo que se puede mejorar, cambiar


  • En nuestra implementación, puede cambiar fácilmente de un almacén de datos a otro. La implementación de almacenamiento se puede inyectar en el código sin cambiar la lógica empresarial. Por ejemplo, puede transferir datos de la memoria a una base de datos escribiendo un adaptador de base de datos.
  • En lugar de enviarlo a la consola, puede implementar una "impresora", que escribirá datos en un archivo. En una aplicación de múltiples capas de este tipo, resulta más fácil agregar funcionalidad y corregir errores.
  • Para probar la lógica empresarial, puede escribir pruebas complejas. Los adaptadores se pueden probar de forma aislada. Por lo tanto, es posible aumentar la cobertura general de la prueba.

Conclusión


Se pueden observar las siguientes ventajas de la arquitectura hexagonal:

  • Acompañamiento : capas sueltas e independientes. Se vuelve fácil agregar nuevas características a una capa sin afectar a otras capas.
  • Testabilidad : las pruebas unitarias se escriben de forma simple y rápida. Puede escribir pruebas para cada capa utilizando objetos de código auxiliar que simulan dependencias. Por ejemplo, podemos eliminar la dependencia de la base de datos haciendo un almacén de datos en la memoria.
  • Adaptabilidad : la lógica empresarial principal se vuelve independiente de los cambios en los objetos externos. Por ejemplo, si necesita migrar a otra base de datos, no es necesario que realicemos cambios en el dominio. Podemos hacer un adaptador apropiado para la base de datos.

Referencias



Eso es todo. ¡Nos vemos en el curso !

All Articles