Cómo organizar un proyecto en Go sin sobrediseñarlo
Estructura de proyecto en Go: paquetes, cmd, internal, handlers, servicios y repositorios. Cuándo la arquitectura ayuda y cuándo solo añade ruido.

Mi primer proyecto en Go venía de años con Java y Spring Boot. Hice lo que cualquier desarrollador Java haría: creé 20 paquetes, tres capas de abstracción, interfaces para todo y un directorio pkg/ porque lo vi en un repositorio con 30.000 estrellas en GitHub. El proyecto era un CRUD que leía de PostgreSQL y devolvía JSON. Técnicamente no estaba equivocado, pero toda esa arquitectura no servía absolutamente para nada. Era sobreingeniería pura, y tardé en darme cuenta.
Go tiene una filosofía muy clara sobre la estructura de proyectos, y esa filosofía se resume en una frase que repiten constantemente en la comunidad: “A little copying is better than a little dependency.” La simplicidad no es un defecto, es una decisión de diseño. Y la estructura de tu proyecto debería reflejar eso.
La filosofía de Go: empieza plano, organiza cuando duela
En Java, empiezas un proyecto y antes de escribir una línea de lógica ya tienes src/main/java/com/empresa/proyecto/controller/, service/, repository/, model/, dto/, config/, exception/… Es el estándar. Nadie lo cuestiona.
En Go, el punto de partida es radicalmente distinto. Un proyecto puede ser un único fichero main.go y funcionar perfectamente. No hay convención impuesta por el framework porque, en la mayoría de casos, no hay framework. No hay un Maven que te fuerce una estructura de directorios. No hay un Spring que espere encontrar tus clases en paquetes concretos.
Esto desorienta al principio —a mí me desorientó bastante, viniendo de Spring—, pero tiene una ventaja enorme: la estructura de tu proyecto refleja la complejidad real de tu proyecto, no la complejidad que un framework te obliga a anticipar.
La regla general que funciona:
- Empieza con todo en el paquete
main. - Cuando un fichero crece demasiado, extrae un paquete.
- Cuando tienes varios binarios, usa
cmd/. - Cuando necesitas proteger paquetes internos, usa
internal/. - Si nada te duele, no reorganices.
Esto no es pereza. Es Go idiomático.
La estructura mínima viable: main.go y poco más
Para un proyecto pequeño (una herramienta CLI, un microservicio sencillo, un script que se queda en producción más de lo que debería), esta estructura es perfectamente válida:
mi-proyecto/
├── go.mod
├── go.sum
├── main.go
├── handler.go
├── service.go
└── repository.goTodo vive en el paquete main. No hay subdirectorios. No hay interfaces abstractas. No hay pkg/. Y funciona.
// main.go
package main
import (
"log"
"net/http"
)
func main() {
repo := NewPostgresRepo("postgres://localhost:5432/mydb")
svc := NewTaskService(repo)
handler := NewTaskHandler(svc)
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks", handler.ListTasks)
mux.HandleFunc("POST /tasks", handler.CreateTask)
mux.HandleFunc("GET /tasks/{id}", handler.GetTask)
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}// handler.go
package main
import (
"encoding/json"
"net/http"
)
type TaskHandler struct {
service *TaskService
}
func NewTaskHandler(s *TaskService) *TaskHandler {
return &TaskHandler{service: s}
}
func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
tasks, err := h.service.GetAll(r.Context())
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(tasks)
}
func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
var t Task
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
created, err := h.service.Create(r.Context(), t)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(created)
}
func (h *TaskHandler) GetTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
task, err := h.service.GetByID(r.Context(), id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(task)
}Esto es un servicio real. Lee de base de datos, expone una API HTTP, tiene separación de responsabilidades. Y no necesita más estructura que cuatro ficheros en la raíz.
El momento de reorganizar llega cuando:
- Tienes más de 10-15 ficheros en la raíz y cuesta encontrar cosas.
- Necesitas reutilizar lógica en más de un binario.
- Otro equipo o módulo necesita importar tus tipos.
Si ninguna de estas cosas pasa, quédate plano. Y siendo honestos, no vas a ganar puntos por tener más carpetas.
El patrón cmd/: cuando tienes varios binarios
El directorio cmd/ aparece cuando tu proyecto genera más de un ejecutable. Un caso típico: tienes una API y un worker que procesa colas, o una API y una herramienta CLI de administración.
mi-proyecto/
├── cmd/
│ ├── api/
│ │ └── main.go
│ └── worker/
│ └── main.go
├── internal/
│ ├── task/
│ │ ├── handler.go
│ │ ├── service.go
│ │ └── repository.go
│ └── shared/
│ └── db.go
├── go.mod
└── go.sumCada subdirectorio dentro de cmd/ tiene su propio main.go con package main. Cada uno compila a un binario independiente.
// cmd/api/main.go
package main
import (
"log"
"net/http"
"mi-proyecto/internal/task"
"mi-proyecto/internal/shared"
)
func main() {
db := shared.NewDB("postgres://localhost:5432/mydb")
repo := task.NewPostgresRepo(db)
svc := task.NewService(repo)
handler := task.NewHandler(svc)
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks", handler.List)
mux.HandleFunc("POST /tasks", handler.Create)
log.Fatal(http.ListenAndServe(":8080", mux))
}// cmd/worker/main.go
package main
import (
"log"
"mi-proyecto/internal/task"
"mi-proyecto/internal/shared"
)
func main() {
db := shared.NewDB("postgres://localhost:5432/mydb")
repo := task.NewPostgresRepo(db)
svc := task.NewService(repo)
log.Println("Worker starting...")
svc.ProcessPendingTasks()
}La clave es que cmd/ solo contiene el punto de entrada. La lógica real está en internal/. Los main.go de cmd/ deberían ser ficheros cortos que inicializan dependencias y arrancan el programa. Si tu main.go tiene más de 50-60 líneas, probablemente estás mezclando configuración con lógica de negocio.
No uses cmd/ si solo tienes un binario. Un main.go en la raíz es más simple y cumple la misma función. A veces la solución más aburrida es la correcta.
internal/: el control de acceso que Go trae de serie
internal/ es probablemente la convención más útil de Go y la menos entendida por los que vienen de otros lenguajes. No es una convención social como src/ en Java o lib/ en Ruby. Es una restricción del compilador. El propio go build impide que código fuera de tu módulo importe paquetes dentro de internal/.
mi-proyecto/
├── internal/
│ └── billing/
│ └── calculator.go // Solo accesible desde mi-proyecto
├── pkg/
│ └── currency/
│ └── formatter.go // Accesible por cualquiera
└── main.goSi otro proyecto intenta hacer import "mi-proyecto/internal/billing", el compilador lo rechaza. No hace falta documentar que es privado, no hace falta confiar en que la gente lea el README. El compilador lo impone.
Esto te da libertad para cambiar la API interna sin preocuparte por romper consumidores externos. Y eso, en la práctica, significa que puedes refactorizar sin miedo. Creo que es una de esas cosas que no valoras hasta que la necesitas de verdad.
Cuándo usar internal/:
- Cuando tu módulo expone algo público (un CLI, una librería) y quieres proteger la implementación.
- Cuando trabajas en un monorepo y quieres que ciertos paquetes solo los use tu servicio.
- Por defecto. Si no tienes una razón para que algo sea público, ponlo en
internal/.
Cuándo NO necesitas internal/:
- Si tu proyecto es un binario que nadie importa. Técnicamente no importa dónde pongas los paquetes porque nadie externo va a usarlos. Pero incluso así,
internal/comunica intención y es una buena costumbre.
Una estructura práctica para backends: handlers, services, repositories
Ahí es donde hay que tomar decisiones reales. Cuando el proyecto crece lo suficiente para necesitar paquetes separados, la pregunta es: ¿organizo por capa técnica o por dominio?
Por capa técnica (evitar en la mayoría de casos)
internal/
├── handlers/
│ ├── task_handler.go
│ ├── user_handler.go
│ └── project_handler.go
├── services/
│ ├── task_service.go
│ ├── user_service.go
│ └── project_service.go
├── repositories/
│ ├── task_repo.go
│ ├── user_repo.go
│ └── project_repo.go
└── models/
├── task.go
├── user.go
└── project.goEsto es la traducción directa de la estructura de Spring Boot. Y funciona, no digo que no. Pero tiene un problema fundamental en Go: genera dependencias circulares con facilidad. El paquete services necesita tipos de models y funciones de repositories. El paquete handlers necesita tipos de models y funciones de services. Cualquier refactoring que cruce capas se convierte en un dolor.
Por dominio (generalmente mejor)
internal/
├── task/
│ ├── handler.go
│ ├── service.go
│ ├── repository.go
│ └── model.go
├── user/
│ ├── handler.go
│ ├── service.go
│ ├── repository.go
│ └── model.go
└── platform/
├── db.go
├── config.go
└── middleware.goCada dominio tiene todo lo que necesita. task/ no necesita importar nada de user/ en la mayoría de casos. Si necesitan comunicarse, defines una interfaz en el paquete que consume y dejas que el otro la implemente.
// internal/task/service.go
package task
import "context"
type UserLookup interface {
GetByID(ctx context.Context, id string) (User, error)
}
type Service struct {
repo Repository
userLookup UserLookup
}
func NewService(repo Repository, ul UserLookup) *Service {
return &Service{repo: repo, userLookup: ul}
}La interfaz UserLookup está definida en el paquete task, no en user. Esto es Go idiomático: las interfaces se definen donde se consumen, no donde se implementan. Es lo contrario de Java, donde defines la interfaz en el paquete del proveedor.
Esta inversión sutil es lo que hace que la organización por dominio funcione sin dependencias circulares. Confieso que me costó interiorizarlo viniendo del mundo JVM, pero una vez lo entiendes, todo encaja.
Si quieres profundizar en cómo aplicar estos principios de forma más formal, lo cubro en detalle en el artículo sobre arquitectura limpia en Go.
El debate de pkg/: por qué muchos desarrolladores de Go lo evitan
Si buscas “golang project structure” en Google, lo primero que encuentras es el repositorio golang-standards/project-layout. Tiene más de 50.000 estrellas y propone una estructura con cmd/, internal/, pkg/, api/, web/, configs/, scripts/, build/, y una docena de directorios más.
El directorio pkg/ se supone que contiene código que puede ser importado por proyectos externos. La idea es que si algo está en pkg/, es “público” y estable.
El problema: pkg/ no tiene ningún significado especial para el compilador. A diferencia de internal/, que el compilador impone, pkg/ es solo un nombre de directorio. No añade protección. No añade funcionalidad. Solo añade un nivel de anidamiento extra en tus imports.
// Con pkg/
import "mi-proyecto/pkg/currency"
// Sin pkg/
import "mi-proyecto/currency"La segunda opción es más corta y no pierde información. Si currency/ está en la raíz del módulo y fuera de internal/, ya es público por defecto.
Russ Cox, uno de los líderes del equipo de Go, ha dicho explícitamente que pkg/ como convención es innecesaria para la mayoría de proyectos. La recomendación oficial es simple: usa internal/ para lo privado, pon lo público en la raíz del módulo o en subdirectorios con nombres descriptivos.
Cuándo tiene sentido pkg/:
- Si tu proyecto tiene muchos directorios en la raíz (configs, scripts, docs, deployments) y quieres agrupar el código Go público en un solo sitio para no perderlo entre el ruido.
- Si ya lo usas y cambiarlo rompería imports de otros proyectos.
Cuándo no tiene sentido:
- En la mayoría de proyectos. Si tu proyecto es un binario (un API, un CLI), nadie importa tu código.
internal/es todo lo que necesitas. - Si eres un equipo pequeño y el “público” de tu código son tus propios servicios.
internal/y la raíz del módulo cubren ese caso.
No copies golang-standards/project-layout sin pensar
Este punto merece su propia sección porque es una trampa en la que caen muchos desarrolladores —yo incluido al principio—, especialmente los que vienen de ecosistemas con estructuras más rígidas.
El repositorio golang-standards/project-layout no es un estándar oficial de Go. El nombre es engañoso. El propio equipo de Go ha dicho públicamente que no lo respalda. Russ Cox abrió un issue pidiendo que se cambiara el nombre porque genera confusión.
El repositorio muestra una estructura completa para un proyecto grande y maduro. Aplicarla a un microservicio nuevo es como diseñar la arquitectura de un centro comercial para abrir un puesto de bocadillos. Técnicamente todo está en su sitio, pero el coste de navegación y mantenimiento no se justifica.
Lo que suele pasar:
- Desarrollador nuevo en Go busca “cómo estructurar proyecto Go”.
- Encuentra
golang-standards/project-layout. - Copia toda la estructura.
- Tiene un proyecto con
cmd/,internal/,pkg/,api/,web/,configs/,deployments/,test/,tools/,examples/,third_party/,build/,assets/,docs/… - El 80% de esos directorios está vacío o tiene un fichero.
- Cada vez que crea un fichero nuevo, tiene que decidir en cuál de 15 directorios va.
Creo que la estructura debería ser una consecuencia de la complejidad del proyecto, no una anticipación. Deja que el proyecto te diga cuándo necesita más estructura. Pero la situación está cambiando poco a poco: cada vez más gente en la comunidad de Go entiende que esa plantilla no es un estándar, y eso ayuda.
Cuándo la estructura sí importa: proyectos medianos y grandes
Todo lo anterior no significa que la estructura dé igual. Hay un punto de inflexión donde la falta de organización empieza a doler. Los síntomas son claros:
- Ficheros de más de 500 líneas donde cuesta encontrar funciones.
- Dependencias circulares entre paquetes que no deberían conocerse.
- Nombres genéricos como
utils/,helpers/,common/que se convierten en cajones de sastre. - Tests que importan medio proyecto porque todo está acoplado.
- Nuevos miembros del equipo que tardan días en entender dónde va cada cosa.
Cuando ves estos síntomas, es el momento de reorganizar. Y el principio es siempre el mismo: agrupa por lo que cambia junto, no por lo que se parece técnicamente.
Si cada vez que tocas una feature de “facturación” tienes que modificar ficheros en handlers/, services/, repositories/ y models/, esos cuatro ficheros deberían estar en un paquete billing/. Si cambiar el handler nunca requiere cambiar el repositorio de otro dominio, están bien separados.
Para gestionar bien esas dependencias entre paquetes en proyectos grandes, tener claro cómo funcionan los módulos en Go es fundamental.
Ejemplos de árboles: del proyecto simple al multi-servicio
Proyecto simple: herramienta CLI o microservicio pequeño
task-api/
├── go.mod
├── go.sum
├── main.go
├── handler.go
├── service.go
├── repository.go
├── model.go
└── README.mdTodo en package main. Sin subdirectorios. Perfecto para un servicio con un dominio, pocos endpoints y un solo binario.
Proyecto API con varios dominios
order-service/
├── cmd/
│ └── api/
│ └── main.go
├── internal/
│ ├── order/
│ │ ├── handler.go
│ │ ├── handler_test.go
│ │ ├── service.go
│ │ ├── service_test.go
│ │ ├── repository.go
│ │ ├── repository_test.go
│ │ └── model.go
│ ├── product/
│ │ ├── handler.go
│ │ ├── service.go
│ │ ├── repository.go
│ │ └── model.go
│ ├── customer/
│ │ ├── handler.go
│ │ ├── service.go
│ │ └── model.go
│ └── platform/
│ ├── config/
│ │ └── config.go
│ ├── database/
│ │ └── postgres.go
│ ├── middleware/
│ │ ├── auth.go
│ │ ├── logging.go
│ │ └── recovery.go
│ └── server/
│ └── server.go
├── migrations/
│ ├── 001_create_orders.sql
│ └── 002_create_products.sql
├── go.mod
├── go.sum
├── Makefile
├── Dockerfile
└── README.mdOrganización por dominio dentro de internal/. Los tests van junto al código que prueban (esto es convención de Go, no en un directorio test/ separado). El paquete platform/ agrupa infraestructura transversal: configuración, base de datos, middleware.
Para ver un ejemplo concreto con endpoints, middleware y PostgreSQL, mira el artículo sobre cómo montar una API REST con Go.
Proyecto multi-servicio (monorepo)
ecommerce/
├── cmd/
│ ├── api/
│ │ └── main.go
│ ├── worker/
│ │ └── main.go
│ └── migrator/
│ └── main.go
├── internal/
│ ├── order/
│ │ ├── handler.go
│ │ ├── service.go
│ │ ├── repository.go
│ │ ├── events.go
│ │ └── model.go
│ ├── inventory/
│ │ ├── service.go
│ │ ├── repository.go
│ │ ├── consumer.go
│ │ └── model.go
│ ├── notification/
│ │ ├── service.go
│ │ ├── email.go
│ │ └── templates.go
│ └── platform/
│ ├── config/
│ │ └── config.go
│ ├── database/
│ │ └── postgres.go
│ ├── messaging/
│ │ └── kafka.go
│ ├── middleware/
│ │ ├── auth.go
│ │ └── logging.go
│ └── observability/
│ ├── metrics.go
│ └── tracing.go
├── migrations/
│ ├── 001_create_orders.sql
│ └── 002_create_inventory.sql
├── deployments/
│ ├── docker-compose.yml
│ └── k8s/
│ ├── api.yaml
│ └── worker.yaml
├── scripts/
│ └── seed.go
├── go.mod
├── go.sum
├── Makefile
└── README.mdTres binarios en cmd/. Dominios claramente separados. Infraestructura compartida en platform/. Ficheros de despliegue en deployments/. Scripts auxiliares en scripts/.
Fíjate que incluso en un proyecto de este tamaño, la estructura sigue siendo razonablemente plana. No hay pkg/. No hay api/ con definiciones OpenAPI separadas. No hay third_party/. Cada directorio existe porque tiene un propósito claro, no porque un template lo sugiera.
Paquetes: las reglas no escritas
Más allá de la estructura de directorios, hay convenciones sobre los propios paquetes que te van a ahorrar problemas:
Nombres de paquetes
- Cortos y descriptivos:
task,order,auth. Notaskmanager,orderprocessing,authentication. - Sin prefijos redundantes: El paquete
taskno necesita que sus tipos se llamenTaskServiceoTaskHandler. En Go, los usas comotask.Serviceytask.Handler. El nombre del paquete ya da contexto. - Nunca
utils,helpers,common,shared: Estos nombres no comunican nada. Si tienes funciones de formateo de moneda, crea un paquetecurrency. Si tienes validadores, un paquetevalidation. El nombre debe decir qué hace, no que es “útil”.
// Mal
utils.FormatCurrency(amount)
// Bien
currency.Format(amount)Dependencias circulares
Go no permite dependencias circulares entre paquetes. Si order importa customer y customer importa order, no compila. Punto.
Esto parece una limitación, y reconozco que la primera vez que me topé con ella pensé que lo era. Pero en la práctica, es un regalo. Te obliga a pensar en la dirección de las dependencias desde el principio. Las soluciones son:
- Interfaces en el consumidor: Ya lo vimos antes. El paquete que necesita algo define una interfaz mínima y el otro la implementa.
- Paquete intermedio: Si dos paquetes necesitan tipos comunes, extrae esos tipos a un tercer paquete que ambos importen.
- Reorganizar: A veces la dependencia circular indica que dos paquetes deberían ser uno solo.
Un fichero por responsabilidad
No hagas ficheros gigantes. Pero tampoco crees un fichero por función. La regla práctica: un fichero por concepto o tipo principal.
internal/order/
├── handler.go // HTTP handlers para orders
├── service.go // Lógica de negocio
├── repository.go // Acceso a datos
├── model.go // Tipos: Order, OrderItem, OrderStatus
├── events.go // Eventos de dominio: OrderCreated, OrderShipped
└── validation.go // Reglas de validación específicas de ordersCada fichero tiene entre 50 y 300 líneas. Si pasa de 400, probablemente está haciendo demasiado.
Conclusión: deja que el proyecto te diga cuándo necesita más
Creo que la estructura de un proyecto en Go debería ser la mínima necesaria para que el equipo (o tú solo) pueda trabajar de forma productiva. No la mínima posible, ni la máxima que se te ocurra. La mínima necesaria.
Empieza plano. Extrae paquetes cuando notes fricción. Usa internal/ por defecto para todo lo que no necesite ser público. Usa cmd/ cuando tengas más de un binario. Organiza por dominio, no por capa técnica. Evita pkg/ salvo que tengas una razón concreta. Y por favor, no copies golang-standards/project-layout entero para un microservicio de cuatro endpoints.
La mejor estructura es la que tu compañero nuevo entiende en cinco minutos. Si necesita un diagrama de 20 cajas para saber dónde crear un fichero, algo ha ido mal.
Go fue diseñado para que el código sea aburrido de leer. Y siendo honestos, que tu estructura de proyecto también lo sea es probablemente lo mejor que le puede pasar.


