DEV Community

loading...

¿Como estructurar tu aplicación en Go?

hdlopez profile image Horacio López Updated on ・11 min read

Objetivo

Cuando comencé a programar en go, me hice muchas preguntas. Preguntas sobre el lenguaje en sí, buenas prácticas, code conventions, naming conventions, cómo escribir código idiomático, etc,... pero creo que una de mis primeras preguntas fue: ¿cómo estructuro mi aplicación?. Luego de haber investigado y haber escrito un par de aplicaciones productivas, mi idea es compartirles una propuesta de cómo estructurar una aplicación en golang (o simplemente go) y dejarles un ejemplo en GitHub fácil de utilizar.

Si buscan en internet realmente hay muchas, muchas, propuestas e ideas (al final comparto algunos links). Muchas de esas basadas en la propuesta de Clean Architecture de Uncle Bob y en Domain Driven Design. En este artículo les comparto una propuesta más, basada en clean architecture, inspirada en artículos que he leído y en español :).

Clean Architecture? y eso?

Como mencionaba, en este gran artículo de hace ya varios años, Uncle Bob describe cuáles considera que deben ser las características de una buena estructura o modularización de una aplicación.

Intentaré describir en español (o spanglish) y usando un poco mis palabras lo que se menciona en el artículo.

Las aplicaciones deben ser:

  1. Independientes de frameworks: la arquitectura no debe tener dependencias con frameworks subyacentes que utilicemos. Esto nos permite flexibilidad ante cambios de framework y no atarnos a las restricciones que cada uno presente .
  2. Testeable: los componentes de la aplicación deben ser testeables, sin necesidad de otros módulos o de tener una UI.
  3. Independiente de la UI: la interfaz con el usuario de la aplicación podría ser un command line, una API, una web y eso debería ser fácilmente intercambiable sin cambiar las reglas de negocio.
  4. Independiente de la base de datos: la forma en que se almacenan los datos debe ser independiente de tus reglas de negocio. Si la aplicación comienza almacenando datos en mysql y luego evoluciona y debe simplemente hacer un POST de esa información a un API externa, deberían ser intercambiables sin modificar reglas de negocio.
  5. Independiente de cualquier ente externo: basicamente las reglas de negocio no deberían saber de nada de lo que sucede por fuera de ellas, ni de DBs, ni de APIs o de cualquier otra cosa.

Siguiendo estos principios según Uncle Bob podemos separar nuestro código en 4 capas:

  • Entities: esta capa encapsula las reglas de negocio de nuestro dominio. Normalmente en go estas entidades son representadas por structs y sus funciones asociadas.
  • Use cases: esta capa contiene reglas de negocio específicas de nuestra aplicación. Aquí se proveen los servicios para cumplir los casos de uso.
  • Interface Adapters / Controller: esta capa consiste en una serie de adaptadores que toman los datos en el formato que los envía el usuario y los transforma para que puedan ser utilizados por nuestra capa de casos de uso.
  • Framework & Driver: esta capa está generalmente compuesta de conectores o frameworks que nos permiten adaptarnos a distintos entes externos, como base de datos, APIs, etc.

Ahora que conocemos más de clean architecture, apliquemos estos principios y separación en capas a una aplicación en go.

Aplicando Clean Architecture en Go

Utilizaremos como base de la explicación una API de mensajes. ¿Por qué una API de mensajes? Solo porque quise salir de la clásica aplicación de los ejemplos que manejan usuarios 👨🏻‍💻. La API que utilizaremos maneja una entidad Message que solo tiene como dato un string, siendo ese string el texto del mensaje (si lo sé, me maté con el ejemplo 😅). Mi idea no es centrarme en una aplicación compleja a nivel lógica de negocio, sino simplemente usarla para mostrar su estructura.

Si visualizamos el proyecto en GitHub o descargamos el ejemplo, la carpeta root se verá más o menos así:

drwxr-xr-x  14 hlopez  staff   448 Nov  3 16:17 .
drwxr-xr-x   3 hlopez  staff    96 Oct 25 18:10 ..
drwxr-xr-x   5 hlopez  staff   160 Oct 30 19:30 api
-rw-r--r--   1 hlopez  staff   198 Oct 25 19:39 main.go
drwxr-xr-x   5 hlopez  staff   160 Nov  6 18:12 message
drwxr-xr-x   4 hlopez  staff   128 Oct 30 20:56 restclient
...
...
...
Enter fullscreen mode Exit fullscreen mode

Como podemos ver, tenemos básicamente 3 módulos api, message y restclient. Existen algunos módulos más en la aplicación, pero son utilitarios y no son relevantes estructuralmente hablando.

Relacionando los módulos con las capas de clean architecture :

  • api - contiene el código que se ubica en la capa Controller
  • message - este módulo contiene código de la capa Entities y de la capa Use cases.
  • ** restclient - contiene código que se ubica en la capa Framework & drivers.

** El módulo restclient es solo un ejemplo, pero podríamos tener más módulos en la capa "Frameworks & drivers" como accesos a mysql, a elastic search, a key value stores (como cassandra), etc.

La aplicación puede ser iniciada haciendo simplemente go run main.go. El código en main.go es extremadamente sencillo.

main.go

package main

func main() {
    // Dependency injection section
    clients := config.RestClients()

    msgAPI := restclient.NewMessageAPI(clients[config.MessageAPI])

    msgRepo := message.NewRepository(msgAPI)

    msgSrv := message.Service(msgRepo)
    msgCtrl := api.NewMessageController(msgSrv)

    // Creates the API intance
    api := api.New(msgCtrl)

    // Runs the application
    api.Run()
}

Enter fullscreen mode Exit fullscreen mode

Como se puede ver, la función main() es la encargada de inicializar los módulos y sus dependencias, para luego iniciar la aplicación usando la función Run().

En la siguientes secciones describo el por qué de la existencia de cada módulo (comenzando con el módulo api). La idea es explicar brevemente las implementaciones y hacia el final ver cómo esta arquitectura nos beneficia en el unit testing.

En todos los snippets de código no se muestra el código completo, con imports y funciones auxiliares por cuestiones de simplicidad. El código completo está disponible en GitHub

Módulo "api"

El módulo api está en la capa Controller. Su objetivo es proveer al usuario medios de interactuar con la capa de servicios (Use cases). En este caso, la interfaz para el usuario es de tipo REST API y está implementada utilizando en el framework gin-gonic.

api/api.go

package api

type API struct {
    pingCtrl *pingCtrl
    msgCtrl  MessageController 
}

func (api *API) Run() {
    r := gin.Default()

    api.configRoutes(r)

    r.Run() 
}

func (api *API) configRoutes(r *gin.Engine) {
    r.GET("/ping", func(c *gin.Context) { api.pingCtrl.Ping(c) })
    r.GET("/messages/:id", func(c *gin.Context) { api.msgCtrl.Get(c) })
}
Enter fullscreen mode Exit fullscreen mode

El tipo API tiene como propiedades los distintos controllers que manejarán el input del usuario y lo transformarán para enviar a la siguiente capa. Un ejemplo, es el caso de message controller definido con el tipo messageCtrl en el archivo messagectrl.go.

api/messagectrl.go

package api

type MessageController interface {
    Get(c Ctx)
}

// messageCtrl handles message entity
type messageCtrl struct {
    srv message.Service
}

func (ctrl *messageCtrl) Get(c Ctx) {
    // 1 - Unmashall user parameters from gin.Context
    id := c.Param("id")
    // 2 - Check and handle validation errors (no business validation)

    // 3 - Make what you need with your request. In this case, get the message by ID.
    msg, err := ctrl.srv.Get(id)
    ...
    ...
}

Enter fullscreen mode Exit fullscreen mode

En este ejemplo, la función Get asociada a messageCtrl toma los parámetros del usuario, los valida y luego los envia a la capa de servicios. Ignoremos en este caso el uso de la variable srv del tipo Service que veremos en la siguiente sección.

Módulo message

Para comenzar, es bueno mencionar que el nombre del módulo refiere al domino de la aplicación, en este caso "mensajes". En este módulo se definen los objetos de dominio que se manejarán y las funciones que implementarán los casos de uso.

En este caso la entidad a manejar es Message.

message/message.go

package message

type Message struct {
    Text string `json:"text"`
}
Enter fullscreen mode Exit fullscreen mode

Extremadamente simple, solo contiene un string que almacena el texto del mensaje. Esta es la entidad que se expone hacia afuera para uso de la capa Controller.

Dentro del módulo message, se tiene también el tipo Service. Esta interfaz expone las funciones que implementan los casos de uso (capa de Use cases). En el ejemplo de la API de mensajes el caso de uso es muy simple: Obtener un mensaje por ID.

message/service.go

package message

type Service interface {
    Get(ID string) (*Message, error)
}

type messageSrv struct {
    repo Repository
}

func (srv *messageSrv) Get(id string) (*Message, error) {
    // validate parameters depending your business logic
    // example: ID must be an UUID v4

    // business logic goes here ...

    // retrieve message using its repository
    msg, err := srv.repo.Get(id)

    // do some stuff

    return msg, err
}
Enter fullscreen mode Exit fullscreen mode

Notar que Service no tiene mayores dependencias más que con la interfaz Repository (tipo que describiremos en breve). Eso hace que la lógica de negocio sea agnóstica a cualquier cambio a nivel almacenamiento.

En la función Get asociada al tipo messageSrv se deben validar los parámetros y luego ejecutar las reglas de negocio correspondientes. En este caso podría ser tan simple como obtener el mensaje desde su repositorio.

Respecto al tipo Repository es una interfaz que brinda una abstracción del acceso a datos. Este código se podría considerar también en la de capa Use cases, en donde los casos de uso que se implementan son los más básicos como: obtener o guardar un Message.

message/repository.go

package message

type Repository interface {
    Get(id string) (*Message, error)
}

type msgRepo struct {
    // It can be easly changed by a database or other storage without touching
    // business logic
    api restclient.MessageAPI
}

func (repo *msgRepo) Get(id string) (*Message, error) {
    // Getting message from the external API.
    msg, err := repo.api.Get(id)
    if err != nil {
        return nil, err
    }

    return build(msg), nil
}
Enter fullscreen mode Exit fullscreen mode

Conceptualmente los repositorios de datos deben interactuar con manejadores de base de datos o conectores con APIs para obtener, almacenar o eliminar información. En el caso del ejemplo, el repositorio realiza una conexión con una API externa que brinda mensajes. En la función Get del tipo msgRepo se hace uso del módulo restclient para lograr dicha conexión.

Módulo "restclient"

El módulo restclient está en la capa Framework & driver. En esta capa la idea es brindar conectores con frameworks u otro entes externos logrando que posibles cambios entre conectores no afecten a la lógica de negocio. Como ya hemos repasado, este es solo un ejemplo, pero podríamos tener más módulos en la capa "Frameworks & drivers" como accesos a mysql, a elastic search, a key value stores (como cassandra), etc.

En este caso, en el módulo restclient se implementa el acceso a una API utilizando el framework resty.

restclient/messageapi.go

package restclient

type MessageAPI interface {
    Get(id string) (*Message, error)
}

type msgAPI struct {
    restAPI
}

...
...

// Get a message from our external Message API
func (api *msgAPI) Get(id string) (*Message, error) {
    url := api.readURL(id)

    msg := new(Message)
    res, err := api.get(url, http.Header{}, msg)

    // type assertion
    msg, _ = res.(*Message)

    return msg, err
}
Enter fullscreen mode Exit fullscreen mode

restclient/restclient.go

package restclient

type restAPI struct {
    readClient *resty.Client
}

func (api *restAPI) get(url string, h http.Header, v interface{}) (interface{}, error) {
    ...

    r, err := req.Get(url)

    // handling error and returns the response properly
    return v, nil
}
Enter fullscreen mode Exit fullscreen mode

Se utiliza la composición de tipos en go, componiendo el tipo restAPI en msgAPI. En el tipo msgAPI se aprovecha de una implementación genérica de GET HTTP usando un cliente rest de resty.

Y la testeabilidad??!

Venimos enfocados en organización, dependencia entre módulos, organización de capas, pero no hablamos aún de algo muy importante que es el testing.

Dada la estructura planteada y la independencia entre capa y capa, es muy sencillo realizar pruebas unitarias de cada uno de nuestros módulos sin depender de módulos externos.

Debajo muestro un ejemplo de cómo probar las funciones de la capa Use cases, en particular del archivo service.go.

message/service_test.go

package message

type mockRepo struct{}

func (repo *mockRepo) Get(id string) (*Message, error) {
    if id == "error" {
        return nil, errors.New("Mocked error")
    }
    return &Message{Text: "TestMessage"}, nil
}

func NewInmemRepository() Repository {
    return new(mockRepo)
}

func Test_messageSrv_Get(t *testing.T) {
    // creates in memory message repository
    repo := NewInmemRepository()
    // create new service instance in order to test
    srv := NewService(repo)

    type args struct {
        id string
    }
    tests := []struct {
        name    string
        args    args
        want    *Message
        wantErr bool
    }{
        {
            "test OK",
            args{"myMessageID"},
            &Message{Text: "TestMessage"},
            false,
        },
        {
            "test fail",
            args{"error"},
            nil,
            true,
        },
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := srv.Get(tt.args.id)
            if (err != nil) != tt.wantErr {
                t.Errorf("messageSrv.Get() error = %v, wantErr %v", err, tt.wantErr)
                return
            }

            if !reflect.DeepEqual(got, tt.want) {
                t.Errorf("messageSrv.Get() = %v, want %v", got, tt.want)
            }
        })
    }
}

Enter fullscreen mode Exit fullscreen mode

Como vemos en el ejemplo, en este caso es muy sencillo implementar un "mock" que nos permita probar la lógica de la función Get sin depender de la lógica subyacente del repositorio (no dependemos si utilizamos una DB, una API, o cualquier otro framework).

En el mock implementado en el ejemplo simulamos con el string "error", un caso de error en el repositorio. De esa forma podemos plantear un caso de éxito y un caso de error muy fácilmente y sin depender de otros módulos.

TIP con esta herramienta en go pueden generar unos hermosos test siguiendo el patrón de Data Driven Testing (DDT) (o también conocido como Table Driven Testing o TDT)

Conclusión

Luego de leer tutoriales, hacer pruebas de concepto, de probar, de equivocarme, de intentar generar código idiomático en go, luego de haber leído decenas de artículos usando los principios de Uncle Bob y su famosa clean architecture, finalmente, este es mi humilde aporte a la comunidad (en español) de cómo estructurar tu aplicación en go.

Usando la estructura del ejemplo de la API de mensajes se puede extrapolar a cualquier otra aplicación. En resumen:

  • api: en este caso nos da la interfaz con el usuario mediante una rest API, pero podría ser un command line u cualquier otro tipo de interfaz. El nombre del módulo cambiaría según el caso.
  • message: en este módulo podemos encapsular nuestra lógica de negocio y casos de uso. El nombre del módulo refiere al domino de la aplicación por lo que podría ser cualquier cosa. Deben ser nombres en singular siguiendo naming conventions de go. Ejemplo: user, customer, item, etc.
  • restclient: este es un simple ejemplo de un módulo externo para dar servicios a la capa de Use case. Otros ejemplos podrían ser, módulos con namings como mysqlhandler, dbhandler, elastichandler, dbutil, siendo todos ellos conectores a distintos gestores de datos.

En mi repositorio de GitHub dejo un ejemplo totalmente funcional que pueden utilizar libremente.

Happy structuring :D (creo que inventé la expresión...)

Referencias

Discussion (1)

Collapse
carlostheone profile image
Developer-eando

Muy buen artículo!

Forem Open with the Forem app