Arquitectura hexagonal en Go

Antes de comenzar a programar, es común preguntarse: ¿Cuál es la mejor manera de estructurar el proyecto? o ¿Qué parte debería desarrollar primero? Si estas cuestiones resultan complicadas, puede ser señal de que no se está aplicando un patrón de arquitectura de software.

En este artículo, deseo compartir mis conocimientos y perspectivas sobre la arquitectura hexagonal, también conocida como el patrón de Puertos y Adaptadores.

Arquitectura hexagonal

Centro

En este tipo de arquitectura, el enfoque principal es el núcleo de la aplicación. Este núcleo es un componente autónomo de la tecnología que alberga toda la lógica empresarial. Debe desestimarse por completo la presencia de otros elementos externos. En otras palabras, el núcleo no debe tener conocimiento sobre cómo se entrega la aplicación ni sobre la ubicación de los datos.

El núcleo puede verse como una "caja" (representada por un hexágono) que es capaz de manejar toda la lógica de negocio sin depender de la infraestructura en la que se despliega la aplicación. Este enfoque permite probar el núcleo de manera independiente y facilita la modificación de los componentes de la infraestructura.

Una vez que se ha definido el componente central, se puede abordar la interacción con elementos externos al núcleo, conocidos como actores.

Actores

Los actores son entidades del mundo real que desean interactuar con el núcleo. Estos pueden ser personas, bases de datos o incluso otras aplicaciones. Los actores se dividen en dos categorías, según quién inicie la interacción:

  • Los actores controladores (o principales) son aquellos que inician la comunicación con el núcleo para invocar un servicio específico. Ejemplos de actores controladores incluyen a un ser humano o una CLI (interfaz de línea de comandos).

  • Los actores controlados (o secundarios) son aquellos que esperan que el núcleo inicie la comunicación. En este caso, es el núcleo el que necesita algo del actor, por lo que envía una solicitud para invocar una acción específica. Por ejemplo, si el núcleo necesita almacenar datos en una base de datos MySQL, establece la comunicación para ejecutar una consulta INSERT en el cliente MySQL.

Es importante entender que los actores y el núcleo utilizan lenguajes distintos. Por ejemplo, una aplicación externa puede enviar una solicitud HTTP para invocar un servicio del núcleo, que no comprende el concepto de HTTP. Otro caso es cuando el núcleo, que no emplea ninguna tecnología específica, necesita almacenar datos en una base de datos MySQL, que utiliza SQL.

Por lo tanto, es necesario contar con un mecanismo que facilite estas traducciones, y aquí es donde entran los puertos y adaptadores.

Puertos

Por un lado, están los puertos, que son interfaces que establecen cómo debe llevarse a cabo la comunicación entre un actor y el núcleo. Según el tipo de actor, los puertos pueden tener diferentes características:

  • Los puertos para los actores del controlador especifican el conjunto de acciones que el núcleo ofrece y pone a disposición del exterior. Cada acción suele corresponder a un caso de uso particular.

  • Los puertos para actores impulsados definen el conjunto de acciones que el actor debe llevar a cabo.

Es relevante destacar que los puertos son parte del núcleo, lo cual es crucial, ya que el núcleo establece las interacciones necesarias para cumplir con los objetivos de la lógica de negocio.

En negro, se encuentran los puertos destinados a los actores que ejercen control. En gris, están los puertos para los actores que son controlados.

Adaptadores

Por otro lado, existen los adaptadores, que se encargan de convertir una solicitud del actor al núcleo y viceversa. Esto es esencial, ya que, como se mencionó anteriormente, los actores y el núcleo utilizan lenguajes distintos.

Un adaptador para un puerto de controlador convierte una solicitud de una tecnología particular en una invocación a un servicio central.

Un adaptador para un puerto controlado transforma una solicitud que no depende de la tecnología del núcleo en una solicitud que sí es específica para el actor.

Inyección de dependencias

Una vez que se ha completado la implementación, es fundamental enlazar los adaptadores a los puertos adecuados. Esto se puede realizar al iniciar la aplicación, lo que nos permite elegir qué adaptador asignar a cada puerto. Este proceso se conoce como "inyección de dependencias". Por ejemplo, si deseamos almacenar datos en una base de datos MySQL, simplemente conectamos un adaptador específico para esa base de datos al puerto correspondiente. Si queremos guardar datos en memoria (para realizar pruebas), conectamos un adaptador de base de datos en memoria a ese puerto.

Esta imagen muestra lo sencillo que es modificar la infraestructura. Solo es necesario cambiar la dependencia.

Estudio de caso: API de MinesWeeper

Ahora que hemos revisado los elementos clave de una arquitectura hexagonal, integremos estos componentes en una aplicación de ejemplo. Crearemos una API para el conocido juego Buscaminas. Para simplificar, implementaremos solo algunas funcionalidades, pero será suficiente para ilustrar el concepto.

Requisitos funcionales:

  • Obtener un juego: dado un ID, debe devolver el juego correspondiente.

  • Crear un nuevo juego: dado un nombre, un tamaño (N) y el número de bombas (B), debe generar y devolver un nuevo juego con un tablero de NxN celdas y B bombas en posiciones aleatorias.

  • Revelar una celda: dado el ID del juego y la posición de la celda, debe revelar la celda y devolver el resultado. Si se encuentra una bomba o se revela la última celda vacía, la partida finaliza.

Como mencionamos anteriormente, en esta arquitectura, todo se centra en el núcleo de la aplicación; por lo tanto, es fundamental comenzar construyendo la lógica de negocio. En este momento, olvide dónde se almacenarán los datos o cómo se servirá la aplicación. Enfóquese en implementar y probar el núcleo.

Nota: Los ejemplos que se presentan a continuación son simplificados. Visite el repositorio en github.com para ver el proyecto completo.

Estructura de la aplicación

├── cmd
├── pkg
└── internal
    ├── core
    │   ├── domain
    │   │   ├── game.go
    │   │   └── board.go
    │   ├── ports
    │   │   ├── repositories.go
    │   │   └── services.go
    │   └── services
    │       └── gamesrv
    │           └── service.go
    ├── handlers
    └── repositories

Centro

Todos los componentes clave (servicios, dominio y puertos) se organizarán en el directorio, ./internal/core.

Dominio

Todos los modelos de dominio se encontrarán en el directorio, ./internal/core/domain. Este directorio alberga la definición de la estructura Go de cada entidad relacionada con el problema de dominio y puede ser utilizada en toda la aplicación.

Nota: No todas las estructuras de Go son modelos de dominio; solo aquellas que están relacionadas con la lógica de negocio.

// ./internal/core/domain/domain.go

package domain

type Game struct {
    ID            string        `json:"id"`
    Name          string        `json:"name"`
    State         string        `json:"state"`
    BoardSettings BoardSettings `json:"board_settings"`
    Board         Board         `json:"board"`
}

type BoardSettings struct {
    Size  uint `json:"size"`
    Bombs uint `json:"bombs"`
}

type Board [][]string

Puertos

Los puertos estarán localizados en el directorio, ./internal/core/ports. Este directorio incluye la definición de las interfaces empleadas para la comunicación con los actores.

// ./internal/core/ports/ports.go

package ports

type GamesRepository interface {
      Get(id string) (domain.Game, error)
      Save(domain.Game) error
}

type GamesService interface {
      Get(id string) (domain.Game, error)
      Create(name string, size uint, bombs uint) (domain.Game, error)
      Reveal(id string, row uint, col uint) (domain.Game, error)
}

Servicios

Los servicios actúan como nuestros accesos al núcleo y cada uno de ellos implementa el puerto correspondiente. Se organizarán en paquetes dentro del directorio, ./internal/core/services.

Vamos a desarrollar nuestro primer caso de uso: "Obtener un juego".

// ./internal/core/services/gamesrv/service.go

package gamesrv

type service struct {}

func New() *service {
    return &service{}
}

func (srv *service) Get(id string) (domain.Game, error) {
  return domain.Game{}, nil
}

Sabemos que, de alguna forma, el juego se almacena en un sistema de almacenamiento. Aunque en este momento no podemos identificar qué agente específico se encarga de esta tarea (ya sea MySQL, AWS DynamoDB o un archivo sencillo), sabemos que la interacción se llevará a cabo a través de un puerto. Por lo tanto, dejaremos esa decisión para más adelante y utilizaremos únicamente ese puerto, al que llamaremos GamesRepository.

// ./internal/core/services/gamesrv/service.go

package gamesrv

type service struct {
    gamesRepository ports.GamesRepository
}

func New(gamesRepository ports.GamesRepository) *service {
    return &service{
        gamesRepository: gamesRepository,
    }
}

func (srv *service) Get(id string) (domain.Game, error) {
    game, err := srv.gamesRepository.Get(id)
    if err != nil {
        return domain.Game{}, errors.New("get game from repository has failed")
    }

    return game, nil
}

Vamos a hacer algo más emocionante: la implementación del segundo caso de uso, que consiste en "Crear un nuevo juego".

// ./internal/core/services/gamesrv/service.go

package gamesrv

type service struct {
    gamesRepository ports.GamesRepository
    uidGen          uidgen.UIDGen
}

func New(gamesRepository ports.GamesRepository, uidGen uidgen.UIDGen) *service {
    return &service{
        gamesRepository: gamesRepository,
        uidGen:          uidGen,
    }
}

func (srv *service) Get(id string) (domain.Game, error) {...}

func (srv *service) Create(name string, size uint, bombs uint) (domain.Game, error) {
    if bombs >= size*size {
        return domain.Game{}, errors.New("the number of bombs is invalid")
    }

    game := domain.NewGame(srv.uidGen.New(), name, size, bombs)

    if err := srv.gamesRepository.Save(game); err != nil {
        return domain.Game{}, errors.New("create game into repository has failed")
    }

    return game, nil
}

Es importante mencionar que el servicio tiene otra dependencia: «uidgen.UIDGen». En este caso, no se trata de un puerto, ya que simplemente delega la creación de identificadores a otro paquete, y no se utiliza para interactuar con otros actores.

Revisa la implementación del último caso de uso: "Revelar una celda" en el repositorio de Github. También puedes observar cómo se prueba el núcleo utilizando GoMock, lo cual recomiendo encarecidamente.

Adaptadores

En este momento, hemos implementado y probado toda la lógica de negocio, lo que nos permite confirmar que nuestra aplicación cumple con los requisitos funcionales.

Es hora de desarrollar los adaptadores para que la aplicación pueda comunicarse con los actores. Tener todos los componentes desacoplados nos brinda la ventaja de poder implementarlos y probarlos de manera independiente, o facilitar la colaboración con otros miembros del equipo.

Adaptador de controlador

Todos los adaptadores de controladores se organizarán en paquetes dentro del directorio, ./internal/handlers.

El juego se ofrecerá a través de HTTP, por lo que este adaptador de controlador debe ser capaz de convertir una solicitud HTTP en una llamada de servicio.

// ./internal/handlers/gamehdl/http.go

package gamehdl

type HTTPHandler struct {
    gamesService ports.GamesService
}

func NewHTTPHandler(gamesService ports.GamesService) *HTTPHandler {
    return &HTTPHandler{
        gamesService: gamesService,
    }
}

func (hdl *HTTPHandler) Get(c *gin.Context) {
    game, err := hdl.gamesService.Get(c.Param("id"))
    if err != nil {
        c.AbortWithStatusJSON(500, gin.H{"message": err.Error()})
        return
    }

    c.JSON(200, game)
}

Adaptador controlado

Todos los adaptadores gestionados se organizarán en paquetes dentro del directorio, ./internal/repositories.

Los modelos de juego se almacenarán en un repositorio de valores clave en memoria. Este adaptador controlado debe adherirse a la interfaz, ports.GamesRepository.

// ./internal/repositories/gamesrepo/memkvs.go

package gamesrepo

type memkvs struct {
  kvs map[string][]byte
}

func NewMemKVS() *memkvs {
  return &memkvs{kvs: map[string][]byte{}}
}

func (repo *memkvs) Get(id string) (domain.Game, error) {
    if value, ok := repo.kvs[id]; ok {
        game := domain.Game{}
        err := json.Unmarshal(value, &game)
        if err != nil {
            return domain.Game{}, errors.New("fail to get value from kvs")
        }

        return game, nil
    }

    return domain.Game{}, errors.New("game not found in kvs")
}

Implementar la aplicación

En la etapa final, es necesario implementar la aplicación. Podemos ofrecer una o varias opciones para hacerlo. Cada opción debe estar contenida en su propio paquete dentro del directorio, ./internal/cmd.

// ./cmd/httpserver/main.go

package main

func main() {
    gamesRepository := gamesrepo.NewMemKVS()
    gamesService := gamesrv.New(gamesRepository, uidgen.New())
    gamesHandler := gamehdl.NewHTTPHandler(gamesService)

    router := gin.New()
    router.GET("/games/:id", gamesHandler.Get)
    router.POST("/games", gamesHandler.Create)

    router.Run(":8080")
}

Ventajas

  • Separación de responsabilidades: cada elemento (núcleo, adaptadores, puertos, etc.) tiene un objetivo claro y no hay confusión sobre sus funciones.

  • Enfoque en la lógica del negocio: al posponer los aspectos técnicos, se puede concentrar en lo que realmente importa, que es la lógica del negocio.

  • Paralelización del trabajo: una vez que se establecen los puertos, es sencillo dividir el trabajo entre diferentes roles. Tener varios miembros del equipo trabajando en componentes bien definidos y desacoplados puede reducir significativamente el tiempo de desarrollo.

  • Pruebas aisladas: cada componente puede ser probado de manera independiente y, lo más importante, el núcleo puede ser evaluado por sí mismo.

  • Facilidad para cambiar la infraestructura: es sencillo modificar la infraestructura. Puedes cambiar de una base de datos MySQL a una de búsqueda elástica sin afectar la lógica del negocio.

  • Proceso guiado: la arquitectura misma orienta sobre cómo deben llevarse a cabo los pasos en el desarrollo. Se comienza con el núcleo, se avanza a los puertos y adaptadores, y finalmente se entrega la aplicación.

Desventajas

  • Demasiado complejo para proyectos pequeños o de corto plazo: es crucial evaluar si esta arquitectura es adecuada para el proyecto en cuestión. Por ejemplo, si el microservicio tiene una única función, puede resultar excesivo. Para proyectos a corto plazo, a veces es mejor optar por una solución más simple. Esta arquitectura es más recomendable para aplicaciones que enfrentan problemas reales en el ámbito empresarial.

  • Sobrecarga de rendimiento: agregar componentes adicionales implica más llamadas a funciones, lo que puede generar una mínima sobrecarga en cada una de ellas. Esto podría ser un inconveniente si el servicio requiere un rendimiento extremadamente alto.

Recursos:

0
Subscribe to my newsletter

Read articles from Cristian Martin Farias directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Cristian Martin Farias
Cristian Martin Farias

Desarrollador apasionado por convertir bugs en funcionalidades (y café en productividad). Especialista en sistemas distribuidos, microservicios y todo lo que hace que la tecnología funcione… o finja que lo hace. Amante del código limpio, aunque mi historial de commits podría decir otra cosa. Siempre aprendiendo, siempre depurando, y ocasionalmente negociando con mi teclado para que coopere.