6 Consejos para Crear Aplicaciones Web con Go
Go es una excelente opción para aplicaciones web — eficiente, rentable y sorprendentemente capaz sin configuración adicional. La librería estándar te lleva muy lejos por sí sola, y cuando necesitas un poco más de estructura, el ecosistema tiene opciones sólidas y estables que no te complican la vida.
En este artículo comparto seis consejos prácticos que aprendí construyendo aplicaciones web en Go. No es teoría — son patrones que uso cada vez que empiezo un proyecto nuevo.
Mira la versión en video aquí:
Consejo 1: Usa chi como router
Podrías usar el net/http ServeMux de la librería estándar, y para proyectos simples eso es perfectamente razonable. Pero en cuanto necesitas parámetros de ruta con nombre, agrupación de rutas o middlewares con alcance específico, vas a querer un router — y chi es la elección correcta.
Chi es liviano, está activamente mantenido y es 100% compatible con net/http. Cada handler y middleware que escribas funciona con chi sin ninguna adaptación. Además incluye un conjunto sólido de middlewares listos para usar: logger, recoverer, request ID, timeout y más — cosas que de otra forma tendrías que escribir tú mismo.
Una configuración básica:
import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
r := chi.NewRouter()
// Middleware global
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
// Ruta pública
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Bienvenido a la página pública"))
})
// Rutas protegidas — middleware aplicado solo a este grupo
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
r.Get("/dashboard", dashboardHandler)
})
http.ListenAndServe(":8080", r)
La funcionalidad de agrupación es una de las mejores cualidades de chi. Aplicas middlewares — como autenticación — solo a las rutas que lo necesitan, sin afectar al resto de la aplicación.
Consejo 2: Usa templates de Go
El paquete html/template de Go es más capaz de lo que la mayoría cree. Escribes HTML estándar y reemplazas las partes dinámicas con variables, condicionales y bucles — todo con protección contra XSS incorporada, ya que el motor de templates escapa el contenido automáticamente según el contexto.
{{ range .Tips }}
<div class="tip">
<h2>{{ .Title }}</h2>
<p>{{ .Description }}</p>
</div>
{{ end }}
Del lado de Go, parseas el template una vez y lo ejecutas por cada request:
type Tip struct {
Title string
Description string
}
type PageData struct {
Tips []Tip
}
func homeHandler(w http.ResponseWriter, r *http.Request) {
tmpl := template.Must(template.ParseFiles("templates/home.html"))
data := PageData{
Tips: []Tip{
{Title: "Usa chi", Description: "Excelente router para apps web en Go."},
{Title: "Usa templates", Description: "Potente y nativo."},
},
}
tmpl.Execute(w, data)
}
Dos cosas importantes para producción: parsea los templates una sola vez al arrancar la aplicación y guarda el resultado en caché — no en cada request. Y cambia de os.DirFS (lecturas de disco, útil en desarrollo) a embed.FS (compilado en el binario) cuando liberes a producción. Una variable de entorno hace este toggle muy sencillo.
Los templates también soportan define y block para composición de layouts, así puedes construir layouts compartidos y reutilizarlos en distintas páginas sin necesidad de un framework.
Consejo 3: Usa singleflight para manejar llamadas concurrentes
Imagina este escenario: tu cache expira en un momento inoportuno y tienes 1000 llamadas concurrentes pidiendo el mismo recurso al mismo tiempo. Sin ninguna protección, eso son 1000 llamadas simultáneas a tu base de datos — una estampida que puede tumbar tu aplicación.
golang.org/x/sync/singleflight resuelve esto de forma elegante. Cuando múltiples llamadas ejecutan con la misma clave al mismo tiempo, singleflight ejecuta la función una sola vez y comparte el resultado con todos los que esperan.
import "golang.org/x/sync/singleflight"
var group singleflight.Group
func getUser(id string) (*User, error) {
v, err, _ := group.Do("user:"+id, func() (interface{}, error) {
return db.FetchUser(id) // se llama una vez, sin importar cuántos esperan
})
return v.(*User), err
}
La diferencia es significativa: sin singleflight haces N llamadas costosas, con él haces una y compartes el resultado. Además es más rápido en conjunto, ya que todos los que esperan reciben la respuesta en cuanto termina la primera llamada.
Un punto importante: los errores también se comparten. Si la llamada subyacente falla, todos los que esperan reciben ese mismo error. Asegúrate de no estar cacheando fallos accidentalmente.
Consejo 4: Middleware
La interfaz http.Handler de Go hace que el middleware sea composable sin ninguna magia — es simplemente una función que envuelve a otra función. Puedes encadenar tantos como necesites.
Chi ya te da un conjunto de middlewares útiles, y deberías usarlos. Pero también vale la pena escribir el tuyo propio al menos una vez — son unas diez líneas y desmitifica exactamente cómo funciona la cadena.
Un ejemplo común y práctico es el middleware de autenticación:
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username, password, ok := r.BasicAuth()
if !ok || !isValid(username, password) {
w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`)
http.Error(w, "No autorizado", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
Si la verificación de autenticación pasa, llama a next.ServeHTTP y la cadena continúa. Si falla, retorna antes — el siguiente handler nunca se ejecuta.
Una advertencia: no uses middleware para pasar datos arbitrarios entre capas. Eso oscurece el flujo de datos en tu aplicación. Usa context.WithValue con moderación, y solo para cosas como IDs de request o el usuario autenticado — no para estado general de la aplicación.
Consejo 5: Inyección de dependencias con fx
A medida que tu aplicación crece, acumulas dependencias: una conexión a la base de datos, un cache, una struct de configuración, múltiples servicios, múltiples handlers. Cablear todo esto manualmente en main.go se convierte en un desastre — y cambiar la firma de un constructor significa actualizarlo en diez lugares.
uber-go/fx resuelve esto de forma limpia. Registras constructores con fx.Provide y fx construye el grafo de dependencias automáticamente. Cada constructor declara lo que necesita como parámetros, y fx se encarga del cableado.
func main() {
fx.New(
fx.Provide(
config.Load, // Config
database.New, // *sql.DB ← necesita Config
NewTipsService, // TipsService ← necesita *sql.DB
NewHomeHandler, // Handler ← necesita TipsService
NewRouter, // chi.Router ← necesita Handler
),
fx.Invoke(StartServer), // ← necesita chi.Router + Config
).Run()
}
fx también tiene hooks de ciclo de vida que se integran limpiamente con el apagado graceful:
func StartServer(lc fx.Lifecycle, r *chi.Mux, cfg config.Config) {
srv := &http.Server{Addr: ":" + cfg.Port, Handler: r}
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
go srv.ListenAndServe()
return nil
},
OnStop: func(ctx context.Context) error {
return srv.Shutdown(ctx)
},
})
}
El resultado es un main.go que se lee como una declaración de lo que necesita la aplicación, no como un diagrama de cableado manual. A medida que agregas más handlers, servicios y dependencias, la estructura se mantiene limpia.
Un trade-off honesto: fx usa reflexión internamente, lo que puede dificultar la lectura de los stack traces. Vale la pena el intercambio una vez que tienes más de tres o cuatro capas. Si prefieres garantías en tiempo de compilación, google/wire es la alternativa.
Consejo 6: Hot reloads con Air
Durante el desarrollo, no deberías estar deteniendo y reiniciando el servidor manualmente cada vez que cambias un archivo. Air se encarga de esto para Go.
Le dices a Air qué archivos y directorios vigilar, y automáticamente reconstruye y reinicia la aplicación cuando detecta un cambio. Archivos Go, templates HTML, archivos estáticos — lo que sea relevante para ti.
Instálalo y agrega un .air.toml a tu proyecto:
[build]
cmd = "go build -o ./tmp/main ."
bin = "./tmp/main"
include_ext = ["go", "html"]
exclude_dir = ["tmp", "vendor"]
Luego ejecuta air en lugar de go run:
air
Una cosa que conviene combinar con esto: si estás cacheando los templates al arrancar (lo cual deberías hacer en producción), agrega una variable de entorno para alternar entre lecturas de disco y archivos embebidos. En desarrollo, lee desde disco para que Air detecte los cambios en los templates sin necesidad de reiniciar.
var tmpl *template.Template
func init() {
if os.Getenv("ENV") == "dev" {
tmpl = template.Must(template.ParseGlob("templates/*.html"))
} else {
tmpl = template.Must(template.ParseFS(embeddedFiles, "templates/*.html"))
}
}
La combinación de Air y lecturas de templates desde disco te da feedback inmediato tanto en cambios de código Go como en cambios de templates — exactamente lo que necesitas cuando estás iterando rápido.
nuzur
Si estás construyendo una aplicación web en Go y quieres saltarte todo el boilerplate de esquemas, echa un vistazo a nuzur. Diseñas tu modelo de datos visualmente — tablas, columnas, relaciones — y nuzur puede generar structs de Go, definiciones protobuf, interfaces de repositorio y más directamente desde él. Sin migraciones SQL manuales, sin copiar y pegar definiciones de esquemas.
El conector MCP también permite que tu asistente de IA consulte tu esquema en vivo mientras programas, para que realmente conozca tu modelo cuando le haces preguntas.
Resumen
- Usa chi — liviano, idiomático, excelente agrupación de middlewares
- Usa templates de Go — parsea una vez al arrancar, usa
embed.FSen producción - Usa singleflight — evita la estampida de caché en misses
- Escribe middleware — composable, explícito, mantén
context.WithValueal mínimo - Usa fx para inyección de dependencias — cableado limpio, hooks de ciclo de vida incorporados
- Usa Air para hot reloads — nunca más reinicies el servidor manualmente
La simplicidad de Go es su característica principal. Estos patrones mantienen esa simplicidad intacta a medida que tu aplicación crece.