Effective Go explicado: cómo escribir Go idiomático sin complicarte
Los principios de Effective Go traducidos a ejemplos modernos y prácticos. Cómo escribir Go idiomático para backend real.

Effective Go se publicó en 2009. Partes del documento han envejecido, hay secciones que asumen un mundo sin módulos, sin generics, sin context.Context. Pero creo que la filosofía que transmite sigue siendo la mejor guía para escribir Go que no pelee contra el lenguaje. El problema, al menos como yo lo veo, es que mucha gente lo lee como una referencia técnica, cuando en realidad es un manifiesto de diseño: te dice qué estilo de código intenta evitar Go y por qué.
He vuelto a releerlo varias veces a lo largo de los años. Y es curioso, porque cada vez que lo hago, entiendo algo que antes me parecía arbitrario. Este artículo es mi traducción de esos principios a ejemplos modernos, aplicados al tipo de backend que escribo hoy. No es un resumen literal del documento. Es lo que me queda después de aplicarlo en proyectos reales, con los aciertos y los errores que eso implica.
Formatting: go fmt no es opcional
La primera sección de Effective Go habla de formato. Y la conclusión es radical: no hay debate sobre estilo en Go. go fmt decide, tú aceptas. Tabs, no espacios. Llaves en la misma línea. Fin de la discusión.
Esto parece trivial, y al principio yo también lo pensé. Pero creo que es una de las decisiones de diseño más inteligentes del lenguaje. En otros ecosistemas gastas horas configurando linters, discutiendo si los imports van ordenados por tipo o por longitud, si las funciones llevan una línea en blanco después del { o no. En Go, eso no existe. Todo el código del mundo tiene el mismo formato.
// Antes de go fmt (esto no compila, pero como ejemplo visual)
func handler(w http.ResponseWriter,r *http.Request){
if r.Method!="POST" {
http.Error(w,"method not allowed",405)
return
}
// ...
}
// Después de go fmt
func handler(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "method not allowed", 405)
return
}
// ...
}El beneficio real no es estético. Es que cualquier diff en un PR muestra solo cambios de lógica, nunca cambios de formato. Los code reviews van más rápido. Los merges tienen menos conflictos. Y los desarrolladores nuevos no necesitan leer un documento de estilo de 40 páginas.
Mi regla: ejecuta gofmt o goimports al guardar. Configúralo en tu editor y olvídate. Si alguien del equipo propone “su propio estilo”, la respuesta es siempre la misma: no.
Naming: nombres cortos, paquetes con significado, cero getters
El naming en Go es radicalmente diferente al de Java o C#. Si vienes de esos mundos, los nombres cortos te van a parecer crípticos al principio. A mí me pasó. Pero tienen una razón que se entiende con el tiempo.
Variables cortas en scope corto
En Go, una variable que vive tres líneas no necesita llamarse userAccountBalance. Le pones b y todo el mundo lo entiende en contexto.
// Esto es Go idiomático
for i, v := range items {
if v.IsActive() {
process(v)
}
}
// Esto NO es Go idiomático
for index, currentItem := range itemsList {
if currentItem.IsActive() {
processItem(currentItem)
}
}La regla es simple: cuanto más corto es el scope, más corto puede ser el nombre. Un parámetro de función que se usa una vez puede ser r para un *http.Request. Un campo de struct que se exporta y vive para siempre necesita un nombre completo y descriptivo.
Nombres de paquetes
El nombre del paquete forma parte del identificador cuando lo usas desde fuera. Por eso http.Server es mejor que httpserver.HTTPServer. Y json.Marshal es mejor que jsonutil.MarshalJSON.
// Bien: el paquete da contexto
user.New("roger", "roger@example.com")
order.Create(cart)
// Mal: redundancia entre paquete y función
user.NewUser("roger", "roger@example.com")
order.CreateOrder(cart)Sin getters
Go no usa getters con prefijo Get. Si tienes un campo name, el getter se llama Name(), no GetName(). El setter sí lleva prefijo: SetName().
type Config struct {
timeout time.Duration
}
// Getter: sin "Get"
func (c *Config) Timeout() time.Duration {
return c.timeout
}
// Setter: con "Set"
func (c *Config) SetTimeout(d time.Duration) {
c.timeout = d
}Esto no es un capricho. Es que config.Timeout() se lee como inglés natural. config.GetTimeout() añade ruido sin aportar información.
Control flow: retornos tempranos, evitar anidación
Effective Go insiste en un patrón que, al menos en mi experiencia, marca la diferencia entre código Go legible y código Go que parece escrito en Java: el early return.
La idea, que suena obvia pero cuesta interiorizar, es que el camino principal (happy path) del código debería estar en el nivel de indentación más bajo posible. Los errores, las validaciones, los casos especiales se gestionan primero y salen de la función cuanto antes.
// MAL: anidación innecesaria
func processOrder(o *Order) error {
if o != nil {
if o.IsValid() {
if o.HasStock() {
// lógica principal aquí, a 3 niveles de indentación
return nil
} else {
return fmt.Errorf("no stock for order %s", o.ID)
}
} else {
return fmt.Errorf("invalid order %s", o.ID)
}
} else {
return errors.New("order is nil")
}
}
// BIEN: early return, happy path a la izquierda
func processOrder(o *Order) error {
if o == nil {
return errors.New("order is nil")
}
if !o.IsValid() {
return fmt.Errorf("invalid order %s", o.ID)
}
if !o.HasStock() {
return fmt.Errorf("no stock for order %s", o.ID)
}
// lógica principal aquí, sin anidación
return nil
}La versión con early return es más larga en líneas pero mucho más fácil de leer. No tienes que hacer un ejercicio mental de “¿en qué nivel de if estoy?”. Cada condición de error está aislada, es fácil de encontrar y de modificar.
Aplico esto de forma casi religiosa, reconozco. Si veo un else después de un return, lo elimino. Si veo más de dos niveles de indentación, busco cómo aplanar. A veces me paso de purista, pero en general creo que compensa.
Error handling: comprueba inmediatamente, envuelve con contexto
Y aquí está el elefante en la habitación. El manejo de errores en Go es la parte que más quejas genera, y siendo honestos, algo de razón hay. El famoso if err != nil que se repite hasta la saciedad.
Pero Effective Go lo plantea de una forma que, con el tiempo, me ha ido convenciendo: los errores son valores. Se devuelven, se comprueban, se propagan. No se ignoran.
// El patrón básico
result, err := doSomething()
if err != nil {
return fmt.Errorf("doing something: %w", err)
}Lo que Effective Go no cubre (porque se escribió antes de Go 1.13) es el wrapping de errores con %w. Hoy es esencial. Cada vez que propagas un error, le añades contexto sobre dónde ocurrió.
func GetUser(ctx context.Context, id string) (*User, error) {
row := db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = $1", id)
var u User
if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("user %s not found: %w", id, ErrNotFound)
}
return nil, fmt.Errorf("scanning user %s: %w", id, err)
}
return &u, nil
}Tres reglas que intento seguir siempre (aunque no siempre lo consigo a la primera):
- Comprueba el error inmediatamente después de la llamada. No hagas tres operaciones y luego compruebes. Cada llamada que puede fallar se comprueba en la siguiente línea.
- Envuelve con contexto.
"scanning user"es infinitamente más útil en un log que unsql: no rowssuelto. Te ahorra horas de debugging. - No uses
_para ignorar errores salvo que tengas una razón documentada. Si crees que un error no puede ocurrir, piénsalo otra vez.
Si quieres profundizar en patrones de errores, tengo un artículo completo sobre errores en Go con errores centinela, tipos personalizados y errors.Is/errors.As.
Interfaces: pequeñas, definidas en el consumidor
Las interfaces en Go son implícitas. No necesitas declarar implements. Si tu tipo tiene los métodos que la interfaz requiere, la implementa. Punto.
Effective Go recomienda interfaces pequeñas. Y con el tiempo, creo que esta es una de las ideas más potentes del lenguaje. Una interfaz con un solo método es perfectamente normal en Go. Y una interfaz con diez métodos es casi siempre una señal de que algo va mal.
// Interfaz estándar de la librería: un solo método
type Reader interface {
Read(p []byte) (n int, err error)
}
// Interfaz de tu dominio: también un solo método
type UserRepository interface {
FindByID(ctx context.Context, id string) (*User, error)
}Defínelas donde se consumen, no donde se implementan
Este es el principio que más cuesta interiorizar si vienes de Java. En Java, defines la interfaz junto a la implementación. En Go, defines la interfaz donde la necesitas.
// En el paquete "order" (consumidor)
package order
type PaymentProcessor interface {
Charge(ctx context.Context, amount int64, currency string) error
}
type Service struct {
payments PaymentProcessor
}
func NewService(p PaymentProcessor) *Service {
return &Service{payments: p}
}// En el paquete "stripe" (implementación)
package stripe
type Client struct {
apiKey string
}
func (c *Client) Charge(ctx context.Context, amount int64, currency string) error {
// llamada al API de Stripe
return nil
}El paquete stripe no importa el paquete order. No sabe que existe una interfaz PaymentProcessor. Simplemente tiene un método Charge que coincide. El acoplamiento va en una sola dirección.
Esto te da una flexibilidad enorme para testing (mocks triviales), para cambiar implementaciones y para mantener los paquetes desacoplados. Lo explico en detalle en el artículo sobre interfaces en Go.
Concurrencia: no compartas memoria, comunica
La frase más citada del ecosistema Go: “Don’t communicate by sharing memory; share memory by communicating.”
Effective Go dedica bastante espacio a goroutines y channels. La idea central es que en vez de proteger datos compartidos con mutexes (que es propenso a errores y difícil de razonar), prefieras enviar datos entre goroutines a través de channels. Dicho así suena sencillo, pero la realidad tiene matices.
// Patrón: fan-out de tareas con channel de resultados
func processItems(ctx context.Context, items []Item) ([]Result, error) {
results := make(chan Result, len(items))
errs := make(chan error, len(items))
for _, item := range items {
go func(it Item) {
r, err := process(ctx, it)
if err != nil {
errs <- err
return
}
results <- r
}(item)
}
var out []Result
for range items {
select {
case r := <-results:
out = append(out, r)
case err := <-errs:
return nil, fmt.Errorf("processing items: %w", err)
case <-ctx.Done():
return nil, ctx.Err()
}
}
return out, nil
}De hecho, hay matices importantes que Effective Go no cubre con la profundidad que necesitas hoy:
- No lances goroutines sin control. Siempre deberías saber cuándo terminan.
sync.WaitGroupo un channel de señalización. - Usa
context.Contextpara cancelación. Toda goroutine que haga I/O debería recibir un contexto y respetarlo. - Los channels no son la solución para todo. Si necesitas un contador atómico, usa
sync/atomic. Si necesitas proteger un mapa de acceso concurrente,sync.RWMutexes más claro que un channel.
La regla práctica: usa channels cuando necesites comunicar datos entre goroutines. Usa mutexes cuando necesites proteger estado compartido simple. No fuerces un channel donde un mutex es más claro.
Composición sobre herencia: embedding, no extensión
Go no tiene herencia. No hay clases, no hay extends, no hay jerarquías de tipos. Esto es intencional, y al principio puede resultar chocante si vienes de lenguajes OOP. Effective Go lo defiende con el concepto de embedding.
El embedding te permite incluir un tipo dentro de otro, promoviendo sus métodos automáticamente. Pero no es herencia, aunque a primera vista lo parezca. No hay polimorfismo de subtipos. Es composición pura.
type Logger struct {
prefix string
}
func (l *Logger) Log(msg string) {
fmt.Printf("[%s] %s\n", l.prefix, msg)
}
// Server embebe Logger
type Server struct {
Logger
addr string
}
func main() {
s := Server{
Logger: Logger{prefix: "HTTP"},
addr: ":8080",
}
// Log() se promueve desde Logger
s.Log("starting server")
}El caso de uso más habitual en backend es embeber sync.Mutex para proteger un struct:
type SafeCounter struct {
sync.Mutex
counts map[string]int
}
func (c *SafeCounter) Increment(key string) {
c.Lock()
defer c.Unlock()
c.counts[key]++
}Lo que hay que evitar: usar embedding como si fuera herencia. Si embebo Database en UserService para “heredar” métodos de acceso a datos, estoy creando acoplamiento innecesario. El embedding debería promover comportamiento que tiene sentido en la interfaz pública del tipo contenedor.
Package design: paquetes cohesivos y enfocados
Effective Go no dedica una sección específica a diseño de paquetes, pero creo que la filosofía se desprende de todo el documento si lees entre líneas. Un paquete en Go debería hacer una cosa bien.
// MAL: paquete "util" que hace de todo
util/
strings.go
http.go
time.go
crypto.go
math.go
// BIEN: paquetes enfocados
httputil/
middleware.go
response.go
order/
service.go
repository.go
model.go
user/
service.go
repository.go
handler.goReglas que aplico:
- Si el nombre del paquete es
util,common,helpersoshared, probablemente necesitas dividirlo. Esos nombres son síntomas de que no has pensado en las responsabilidades. - Evita dependencias circulares. Go no las permite a nivel de compilación, pero eso te obliga a pensar en la dirección de las dependencias desde el principio.
- Un paquete no debería tener más de un nivel de subdirectorios salvo que sea una librería grande. Para la mayoría de aplicaciones backend, una estructura plana funciona mejor.
- Los paquetes
internal/son tus amigos. Todo lo que no quieras exponer fuera de tu módulo va ahí.
Si quieres ver cómo organizo proyectos reales en Go, lo explico en el artículo sobre estructura de proyecto.
Comentarios: explica el porqué, no el qué
Effective Go tiene una posición clara sobre comentarios: los buenos comentarios explican por qué, no qué. El código ya dice qué hace. Si necesitas un comentario para explicar qué hace el código, probablemente el código es demasiado complicado.
// MAL: el comentario repite lo que el código dice
// Incrementa el contador
counter++
// MAL: el comentario describe la implementación obvia
// Itera sobre los usuarios y filtra los activos
for _, u := range users {
if u.Active {
active = append(active, u)
}
}
// BIEN: el comentario explica una decisión no obvia
// Usamos un buffer de 100 porque el productor puede generar ráfagas
// de hasta 80 eventos por segundo y el consumidor procesa ~50/s.
ch := make(chan Event, 100)
// BIEN: el comentario explica un workaround
// El driver de PostgreSQL devuelve un error genérico cuando la conexión
// se cierra por timeout del servidor. Lo reintentamos una vez antes de
// propagar el error al caller.
result, err := retryOnce(func() (*Result, error) {
return db.QueryContext(ctx, query, args...)
})Comentarios de documentación
En Go, los comentarios que preceden una declaración exportada son documentación. godoc los extrae automáticamente. Deberían empezar con el nombre del elemento que documentan.
// OrderService gestiona la lógica de negocio de pedidos.
// Coordina entre el repositorio de pedidos, el servicio de pagos
// y las notificaciones al usuario.
type OrderService struct {
// ...
}
// Create valida y persiste un nuevo pedido.
// Devuelve ErrInvalidOrder si el pedido no pasa las validaciones
// y ErrPaymentFailed si el cobro no se puede completar.
func (s *OrderService) Create(ctx context.Context, o *Order) error {
// ...
}Lo que Effective Go no cubre
El documento se escribió en 2009 y no se ha actualizado para reflejar cambios importantes del lenguaje. Y aquí está el problema: hay tres áreas que necesitas aprender por separado, porque Effective Go simplemente no las cubre:
Generics (Go 1.18+)
Los genéricos cambian la forma de escribir funciones y tipos reutilizables. Effective Go no dice nada sobre ellos porque no existían.
// Antes de generics: una función por tipo
func ContainsString(slice []string, target string) bool { ... }
func ContainsInt(slice []int, target int) bool { ... }
// Con generics: una función para todos
func Contains[T comparable](slice []T, target T) bool {
for _, v := range slice {
if v == target {
return true
}
}
return false
}Mi posición, que reconozco puede ser conservadora: usa generics cuando la alternativa es duplicar código o usar interface{}. No los uses para abstracciones prematuras. La mayoría de código backend no necesita generics.
Modules (Go 1.11+)
Effective Go asume GOPATH. Hoy usamos módulos. go.mod y go.sum gestionan dependencias de forma determinista. Si estás empezando con Go hoy, ni necesitas saber qué era GOPATH.
// go.mod típico
module github.com/rogerbcn/myservice
go 1.22
require (
github.com/gin-gonic/gin v1.10.0
github.com/jackc/pgx/v5 v5.6.0
)Context (Go 1.7+)
context.Context es hoy omnipresente en código Go de backend. Se usa para cancelación, timeouts y propagación de valores entre capas. Effective Go no lo menciona.
func (s *Service) GetOrder(ctx context.Context, id string) (*Order, error) {
// El contexto se propaga a la query de base de datos
row := s.db.QueryRowContext(ctx, "SELECT * FROM orders WHERE id = $1", id)
// Si el contexto se cancela (timeout del HTTP handler, por ejemplo),
// la query se cancela automáticamente
var o Order
if err := row.Scan(&o.ID, &o.Total, &o.Status); err != nil {
return nil, fmt.Errorf("getting order %s: %w", id, err)
}
return &o, nil
}La regla: context.Context siempre es el primer parámetro de una función. Nunca lo almacenes en un struct. Propágalo siempre hacia abajo.
Las 10 reglas que aplico a diario
Después de varios años escribiendo Go para backend, estos son los principios de Effective Go destilados en las reglas que intento aplicar a diario. No son originales ni revolucionarias, pero me han dado buenos resultados:
Ejecuta
go fmtal guardar. Sin excepciones. El formato no es negociable.Nombres cortos para scopes cortos.
rpara un request,ctxpara un contexto,errpara un error. Nombres largos solo para cosas que viven mucho o se exportan.Early return siempre. Si puedes salir de la función antes, hazlo. El happy path va en el nivel más bajo de indentación.
Comprueba errores inmediatamente. Nunca acumules operaciones sin comprobar. Envuelve con
fmt.Errorf("contexto: %w", err).Interfaces de un método. Si tu interfaz tiene más de tres métodos, probablemente necesitas dividirla. Define las interfaces donde se consumen.
No lances goroutines sin saber cuándo terminan. Usa
sync.WaitGroup, channels con señal, oerrgroup.Group.Composición, no herencia. Embebe tipos cuando tiene sentido para la interfaz pública. No lo uses como sustituto de herencia.
Paquetes con un solo propósito. Si el nombre es
utilocommon, repensa la estructura.Comenta el porqué, no el qué. Los comentarios de documentación empiezan con el nombre del elemento. Los comentarios inline explican decisiones, no sintaxis.
context.Contextcomo primer parámetro. Propágalo siempre. Nunca lo guardes en un struct.
Ninguna de estas reglas es original, como decía. Todas vienen, directa o indirectamente, de Effective Go y de la cultura que generó. El documento original merece una lectura completa al menos una vez. Pero si te quedas solo con estas diez reglas y las aplicas de forma consistente, creo que tu código Go va a ser más legible, más mantenible y más idiomático que la mayoría del que hay ahí fuera.
Y al final, lo que diferencia al Go idiomático del Go “funcional pero raro” no es conocer trucos avanzados. Es respetar las convenciones del lenguaje de forma sistemática. Effective Go te dice exactamente cuáles son esas convenciones. Lo demás es práctica. Y tiempo. Y equivocarse lo suficiente para entender por qué esas convenciones existen.


