Estructura de proyectos Go recomendada: cmd/, internal/, pkg/ y cuándo usar cada una Go

Estructura de proyectos Go recomendada: cmd/, internal/, pkg/ y cuándo usar cada una

DUGLAS MORENODUGLAS MORENO 👁 9

Estructura de proyectos Go recomendada: cmd/, internal/, pkg/ y cuándo usar cada una

El ecosistema Go es conocido por su sencillez y eficacia. A pesar de que un programa Go puede ser un solo archivo main.go, los proyectos reales suelen crecer y volverse difíciles de manejar si no se adopta una estructura clara. El proyecto estándar de Go, presentado en el inicio de los módulos, ofrece un layout sencillo y convencional que la mayoría de los equipos adoptan como punto de partida.

Esta guía profundiza en tres directorios fundamentales: cmd/, internal/ y pkg/. Explicaremos qué va en cada uno, cuándo debes usarlos y te mostraremos un ejemplo completo de árbol de archivos con el código correspondiente y el resultado esperado cuando ejecutas la aplicación.

Por qué una estructura clara es importante

Cuando un proyecto es pequeño, es fácil mantener todo en un mismo directorio. Sin embargo, a medida que se agregan nuevas características, la base de código comienza a parecerse a un gallinero virtual: archivos de ayuda dispersos, pruebas difíciles de ubicar y una sensación constante de que algo está en el lugar equivocado.

Una estructura bien definida brinda múltiples beneficios:

  1. Claridad para los nuevos miembros del equipo – Saber que internal/ contiene el núcleo de negocio y cmd/ contiene los binarios de entrada ayuda a que el onboarding sea más rápido.
  2. Control de dependencias – Al mantener el código compartido fuera del alcance de los binarios, se reduce el riesgo de importaciones circulares.
  3. Seguridad de tipos y organización – Go utiliza el case sensitive del sistema de archivos para el visibilidad: los nombres en mayúscula pueden ser referenciados desde fuera del paquete, mientras que los nombres en minúscula solo pueden ser referenciados dentro del paquete.
  4. Facilidad de pruebas y construccióngo build, go test y go vet funcionan de manera consistente con esta disposición.
  5. Escalabilidad – Puedes agregar subcomandos, paquetes de infraestructura o bibliotecas externas sin reordenar archivos.

En las próximas secciones, exploraremos el propósito de cmd/, internal/ y pkg/, junto con las situaciones en las que cada uno brilla.


El layout convencional para un servicio web típico

A continuación, presentamos un layout minimalista y ampliamente adoptado. El árbol que sigue representa una aplicación web Go moderna que expone un endpoint de salud, gestiona usuarios y escribe logs.

project/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── handler/
│   │   └── health.go
│   ├── service/
│   │   └── api.go
│   └── model/
│       └── user.go
├── pkg/
│   └── utils/
│       └── helpers.go
├── go.mod
├── go.sum
└── README.md

Examinemos el rol de cada carpeta y sus archivos.


cmd/ – Punto de entrada y binarios de usuario final

cmd/ es el único lugar donde debería vivir un ejecutable. Es el “punto de entrada” para los usuarios finales, operaciones de administración o cualquier interfaz de línea de comandos. Go almacena binarios separados en subdirectorios individuales bajo cmd/.

Por qué cmd/ es especial

  • Compilación de binarios: go build ./cmd/server compila un ejecutable llamado server (o server.exe en Windows) listo para ser desplegado.
  • Prácticas de despliegue: No hay código compartido en este directorio, lo que simplifica pruebas de integración rápidas (por ejemplo, go test ./cmd/server aún funciona, pero normalmente querrás pruebas unitarias en la lógica de negocio).
  • Separación de preocupaciones: Una aplicación típica de Go separa el “arranque” del “negocio”. El arranque puede ser opcional (por ejemplo, una rutina de migración) mientras que el binario principal contiene el servidor.

Contenido típico de cmd/server/main.go

// cmd/server/main.go
package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "example.com/project/internal/handler"
    "example.com/project/internal/service"
)

func main() {
    // Inicializar paquetes de servicio
    svc := service.NewAPI()
    h := handler.NewHealth(svc)

    mux := http.NewServeMux()
    mux.HandleFunc("/health", h.Check)
    // Rutas adicionales…

    srv := &http.Server{Addr: ":8080", Handler: mux}
    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("listen: %s
", err)
        }
    }()

    // Esperar señal de interrupción para apagado graceful
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    if err := srv.Shutdown(ctx); err != nil {
        log.Fatal("Server Shutdown:', err)
    }
    log.Println("Servidor apagado gracefully")
}

Salida esperada cuando ejecutas go run ./cmd/server:

2023/11/15 12:34:56 Servidor escuchando en :8080
Presiona Ctrl+C para detener…

(Cuando se envía una señal de interrupción, el mensaje de apagado aparece.)

Cuándo usar cmd/

  • Binarios de usuario final: Servidor API, CLI, herramientas, daemons.
  • Varias interfaces de usuario: Puedes agregar cmd/web, cmd/cli, etc., cada una con su propio main.go.
  • Comandos de administración o de mantenimiento: cmd/migrate, cmd/seed.

No pongas librerías aquí; mantén cmd/ libre de código reutilizable.


internal/ – Código compartido y núcleo de negocio

internal/ es el corazón del proyecto. Contiene todos los paquetes reutilizables que pertenecen al módulo. La convención es que los nombres de los paquetes empiecen con minúscula (por ejemplo, handler, service, model), lo que impide que otras personas los importen desde fuera del módulo a menos que exporten intencionalmente (exportar significa “capitalizado” en Go).

Por qué internal/ es seguro

  • Restricción de visibilidad: Go prohíbe importar paquetes desde internal/ desde fuera del módulo. Esto significa que no puedes accidentalmente crear dependencias circulares.
  • Integración de pruebas: Las pruebas viven junto al código, es decir, internal/handler/health_test.go para handler/health.go. Este acoplamiento cercano hace que sea más fácil ejecutar go test ./... y obtener cobertura.
  • Empaquetado semántico: Los cambios en internal/ se reflejan en todos los binarios sin necesidad de versionarlos como librerías externas.

Ejemplos típicos

internal/handler/health.go

// internal/handler/health.go
package handler

import (
    "encoding/json"
    "net/http"

    "example.com/project/internal/service"
)

type HealthCheck struct {
    Status    string `json:"status"`
    Timestamp string `json:"timestamp"`
}

type Health struct {
    svc *service.API
}

func NewHealth(svc *service.API) *Health {
    return &Health{svc: svc}
}

func (h *Health) Check(w http.ResponseWriter, r *http.Request) {
    // Lógica de negocio simple – delega al servicio
    status, ts := h.svc.GetStatus()
    resp := HealthCheck{Status: status, Timestamp: ts}
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(resp)
}

internal/service/api.go

// internal/service/api.go
package service

import (
    "fmt"
    "time"
)

type API struct {
    Version string
}

func NewAPI() *API {
    return &API{Version: "1.0.0"}
}

func (a *API) GetStatus() (string, string) {
    return "OK", time.Now().UTC().Format(time.RFC3339)
}

internal/model/user.go

// internal/model/user.go
package model

type User struct {
    ID        int
    Name      string
    Email     string
    CreatedAt time.Time
}

// Puedes agregar métodos de ayuda, validaciones, etc.

Organización de subdirectorios

Algunas organizaciones mantienen una estructura plana (internal/health.go, internal/service/api.go), mientras que otras prefieren subdirectorios para aislar dominios lógicos:

internal/
├── handler/
│   ├── health.go
│   └── user.go
├── service/
│   ├── api.go
│   └── user.go
└── model/
    └── user.go

Elige lo que sea más legible para tu equipo; lo importante es respetar el prefijo de minúsculas y el campo lexical de internal.

Cuándo usar internal/

  • Paquetes de lógica de negocio central que deseas compartir entre múltiples binarios.
  • Paquetes de infraestructura (logging, base de datos, HTTP clients) que son necesarios para arranque y pruebas.
  • Todo lo que no sea un comando de usuario final. Si un paquete está destinado a ser usado solo dentro de tu módulo, pertenece a internal/.

Nunca pongas librerías externas de terceros en internal/ – esas van a pkg/ (o mejor aún, directamente en vendor/ si aún usas vendorizando, aunque el estándar actual es depender de módulos).


pkg/ – Código legado, snippets de terceros o dependencias externas

pkg/ es menos convencional hoy en día, pero aún aparece en proyectos maduros:

  • Librerías externas antiguas que no han sido trasladadas a internal/.
  • Snippets o prototipos que pueden convertirse en internal/ en una refactorización futura.
  • Código que rompe la regla de visibilidad de internal/ – por ejemplo, un paquete que necesita ser importado por otro módulo (aunque en módulos modernos normalmente querrías usarlo desde replace o subirlo a un registro público).

Debido a que pkg/ no tiene restricciones de visibilidad, debes revisar cuidadosamente los imports para asegurarte de que no se importen accidentalmente desde otros paquetes dentro del mismo módulo, lo que puede ocultar dependencias ocultas.

Ejemplo de snippet legado

// pkg/utils/helpers.go
package utils

// ReverseByte invierte el orden de los bits de un byte – una pequeña utilidad heredada.
func ReverseByte(b byte) byte {
    var result byte
    for i := 0; i < 8; i++ {
        result = (result << 1) | (b & 1)
        b >>= 1
    }
    return result
}

Cuándo usar pkg/

  • Antes de la transición a módulos – proyectos antiguos con un pkg/ que contiene código que no es núcleo pero sigue siendo parte del módulo.
  • Fragmentos experimentales – prototipos que no están listos para ser promovidos a internal/.
  • Dependencias de terceros copiadas – aunque esto generalmente se prefiere como un vendor directo (./vendor) hoy en día.

Punta: Si tienes un pkg/ que contiene muchos paquetes, considera reubicar aquellos que son parte de tu lógica de negocio en internal/ para mantener el significado claro.


Construcción e integración continua con este layout

go build

# Construir el binario del servidor
$ go build ./cmd/server
# Resultado: ejecutable 'server' (o 'server.exe' en Windows)

go run

# Ejecutar el servidor localmente (útil para prototipado rápido)
$ go run ./cmd/server
# Salida esperada:
# 2023/11/15 12:34:56 Servidor escuchando en :8080
# Presiona Ctrl+C para detener…

go test

# Ejecutar todas las pruebas unitarias en el módulo
$ go test ./...
# Resultado (ejemplo):
# ok   example.com/project/internal/handler   0.123s
# ok   example.com/project/internal/service  0.098s
# ok   example.com/project/internal/model   0.045s
# ok   example.com/project/pkg/utils       0.032s
# ok   example.com/project/cmd/server      0.078s

go vet y staticcheck

$ go vet ./...
$ staticcheck ./...

Estos pasos son más fáciles cuando cada paquete está en su propio directorio; los analizadores pueden detectar inconsistencias de nombres y dependencias ciclicas fácilmente.


Buenas prácticas y errores comunes

1. Mantén internal/ en minúsculas

// internal/mymodule  // DEBE ser internal/mymodule (nombre en minúsculas)
package mymodule // OK – nombre en minúsculas

// internal/MyModule // MAL – nombre en mayúsculas, visible para otros módulos
package MyModule

2. Nunca pongas código en cmd/ que sea reutilizable

Si ves cmd/server/main.go conteniendo lógica de negocio, está en el lugar equivocado. Mueve esa lógica a internal/ y reexporta solo lo necesario.

3. Evita dependencias cruzadas entre internal/ y cmd/

cmd/ debe importar solo internal/. Si internal/ importa cmd/, has creado un ciclo. Elimina esas importaciones.

4. Mantén go.mod actualizado

Usa go mod tidy regularmente. Esto asegura que las dependencias externas se declaren solo en go.mod y no accidentalmente en internal/.

5. Usa replace con cuidado

Si necesitas trabajar con un fork, usa replace en go.mod en lugar de copiar el código en pkg/.

6. Tests en internal/

Cada paquete internal debe tener un archivo *_test.go correspondiente que contenga pruebas. El case sensitive del sistema de archivos en Go significa que internal/foo/foo.go solo puede ser probado por internal/foo/foo_test.go. Mantén esta regla consistente.

7. No uses pkg/ para nuevo código

Nuevo código debe ir a internal/. pkg/ es solo para código heredado o snippets externos. Esta regla hace que la base de código sea predecible.


Extendiendo el layout: directorios opcionales

Una vez que la estructura básica se estabiliza, puedes agregar directorios adicionales sin romper la convención:

project/
├── cmd/
├── internal/
├── pkg/
├── api/               // Definiciones de API (ej. protobuf, OpenAPI)
├── docs/              // Documentación
├── scripts/           // Utilidades de CI/CD (ej. entrypoint.sh)
├── Dockerfile        // Imagen de contenedor
└── Makefile           // Tareas comunes (test, build, lint)

Mantén estos directorios fuera de cmd/, internal/ y pkg/ para que no interfieran con el layout principal.


Resumen y próximos pasos

  • cmd/ = Binarios de usuario final (ej. cmd/server/main.go).
  • internal/ = Núcleo de negocio reutilizable (paquetes en minúsculas).
  • pkg/ = Código heredado, prototipos o snippets externos – usa con moderación.

Al seguir esta estructura:

  • Los binarios siempre se construyen a partir de un solo punto de entrada.
  • La lógica de negocio permanece aislada y no se expone accidentalmente.
  • Las pruebas viven junto al código al que están probando.
  • La escalabilidad está integrada; puedes agregar más comandos (cmd/cli) o paquetes (internal/foo) sin desordenar el árbol.

Próximos pasos para un equipo:

  1. Migra cualquier código compartido que se encuentre actualmente en pkg/ a internal/ (manteniendo la visibilidad y la propiedad del paquete en mente).
  2. Renombra cualquier cmd/ que contenga fragmentos de librería a un comando real; elimina el código extra.
  3. AñadeMakefile o GitHub Actions para un flujo de trabajo de go build, go test, go vet unificado.
  4. Documenta el layout en una README que apunte a go help modules para futuras referencias.

Con este enfoque, tu proyecto Go no solo se parecerá al estándar de la comunidad, sino que también estará listo para crecer sin sacrificarse en claridad o rendimiento.


Consejo final

Recuerda, un buen layout es tanto una convención como una declaración de intenciones. Siéntete libre de ajustarlo, pero hazlo explícito en tu README. Una estructura clara reduce fricciones técnicas y permite que tu equipo se concentre en lo que realmente importa: construir grandes cosas con Go.

¡Feliz programación! 🚀

Comentarios (0)

Sé el primero en comentar.

Dejá tu comentario