Go sobre Kotlin: la decisión que no esperaba tomar

Jorge SaavedraJorge Saavedra
·30 de marzo, 2026·14 min de lecturaNuevo
gokotlinconcurrenciagoroutineschannelsopinioncomparativa
Durante años, Kotlin fue mi lenguaje por defecto para todo lo que tenía que ver con backend. Spring Boot, microservicios, APIs REST, workers: todo pasaba por Kotlin y la JVM. El lenguaje me encantaba: expresivo, conciso, con null safety
null safety

Característica del sistema de tipos que obliga al programador a manejar explícitamente los valores nulos, evitando los famosos NullPointerException en tiempo de ejecución.

real y una comunidad sólida. No tenía razón para buscar otra cosa.
Hasta que empecé a trabajar en proyectos donde Go era el estándar del equipo. Al principio lo veía como un paso atrás: sin genéricos (en ese entonces), sin extension functions, sin el ecosistema de Spring. Pero conforme fui escribiendo más Go, algo cambió. Los microservicios arrancaban en milisegundos, los deploys eran un solo binario, y el modelo de concurrencia me hizo replantear todo lo que creía saber sobre programación asíncrona.
Este post no es una guía objetiva de "cuál es mejor". Es mi experiencia personal: por qué Go me resolvió problemas reales que Kotlin no podía, y cómo terminó convirtiéndose en mi primera opción para la mayoría de lo que construyo hoy.

El modelo de concurrencia que me cambió la cabeza

Tanto Kotlin como Go resuelven concurrencia de forma moderna, pero con filosofías opuestas. Kotlin te da structured concurrency
structured concurrency

Modelo de concurrencia donde las tareas asíncronas están vinculadas a un scope con ciclo de vida definido. Si el scope se cancela, todas las tareas hijas se cancelan automáticamente.

sobre la JVM: necesitas un CoroutineScope, eliges un Dispatcher, marcas funciones como suspend, y lanzas coroutines con launch o async. Es poderoso, pero tiene ceremonia.
Go toma un camino radicalmente diferente: la concurrencia es ciudadano de primera clase del lenguaje. Escribes go antes de cualquier función y ya tienes una goroutine corriendo. No hay scopes, no hay dispatchers, no hay funciones coloreadas
funciones coloreadas

Concepto de diseño de lenguajes donde las funciones asíncronas tienen una firma diferente a las síncronas (como suspend en Kotlin o async en JavaScript), creando dos 'colores' de funciones que no se pueden mezclar libremente.

. El runtime de Go se encarga de multiplexar miles de goroutines en un puñado de OS threads con un scheduler M:N
scheduler M:N

Modelo de scheduling donde M unidades de trabajo livianas (goroutines) se multiplexan sobre N hilos del sistema operativo. El runtime decide automáticamente qué goroutine corre en qué hilo, sin intervención del programador.

que trabaja de forma transparente. Y una goroutine cuesta apenas 2KB de stack, contra 1MB de un thread tradicional de la JVM.
Cuando decimos M:N, la M representa la cantidad de goroutines y la N la cantidad de OS threads reales. En la práctica, el runtime de Go crea un número pequeño de threads del sistema operativo (normalmente uno por núcleo de CPU) y distribuye todas las goroutines entre ellos. Tu servicio puede tener 10,000 goroutines activas corriendo sobre 4 o 8 threads. El scheduler se encarga de pausar y reanudar goroutines automáticamente cuando hacen operaciones de I/O, sin que tú tengas que pensar en qué hilo corre cada cosa. En Kotlin, esa decisión la tomas tú al elegir un Dispatcher.
Si ya conoces coroutines, tengo un post dedicado a Kotlin Coroutines que complementa bien esta comparativa. Acá vamos a ver el mismo problema resuelto en ambos lenguajes.
Supongamos que queremos hacer dos llamadas HTTP concurrentes y combinar sus resultados. En Kotlin:
kotlin
import kotlinx.coroutines.*

suspend fun fetchUser(): String {
    delay(100) // simulates HTTP call
    return "user-data"
}

suspend fun fetchOrders(): String {
    delay(150) // simulates HTTP call
    return "order-data"
}

fun main() = runBlocking {
    val user = async(Dispatchers.IO) { fetchUser() }
    val orders = async(Dispatchers.IO) { fetchOrders() }

    println("User: ${user.await()}, Orders: ${orders.await()}")
}
Y el equivalente en Go:
go
package main

import (
    "fmt"
    "time"
)

func fetchUser() string {
    time.Sleep(100 * time.Millisecond) // simulates HTTP call
    return "user-data"
}

func fetchOrders() string {
    time.Sleep(150 * time.Millisecond) // simulates HTTP call
    return "order-data"
}

func main() {
    userCh := make(chan string)
    ordersCh := make(chan string)

    go func() { userCh <- fetchUser() }()
    go func() { ordersCh <- fetchOrders() }()

    user := <-userCh
    orders := <-ordersCh

    fmt.Printf("User: %s, Orders: %s\n", user, orders)
}
Algunas diferencias clave: en Kotlin necesitas runBlocking para entrar al mundo de las coroutines, async para lanzar trabajo concurrente, Dispatchers.IO para elegir dónde corre, y await() para obtener el resultado. En Go, solo necesitas go y un channel. No hay funciones "coloreadas": fetchUser() no necesita ser marcada como suspend, simplemente funciona en una goroutine sin cambiar su firma.

Sin funciones coloreadas

En Go, cualquier función puede correr en una goroutine sin modificar su firma. No existe el concepto de suspend ni async. Esto elimina una categoría entera de complejidad: no hay que decidir si una función es "síncrona" o "asíncrona" al momento de diseñarla.

Modelo de concurrencia: Kotlin vs Go

Kotlin CoroutinesCoroutineScopeDispatcher (IO / Default)Thread 1Thread 2Thread Nsuspend / resumecoroutinecoroutinecoroutinecoroutineGo GoroutinesGo Runtime SchedulerM:N scheduling (automático)OS Thread 1OS Thread 2go keywordgoroutinegoroutinegoroutinegoroutinegoroutine2KB por goroutine

Channels: comunicación entre goroutines

Don't communicate by sharing memory; share memory by communicating.

Rob Pike, co-creador de Go. Esta frase resume la filosofía central de la concurrencia en Go: en vez de que múltiples goroutines accedan a la misma memoria protegida con locks, se pasan datos entre sí a través de channels.
Este mantra define la filosofía de Go. En la práctica, eso significa que en vez de proteger datos compartidos con mutexes y locks, las goroutines se pasan datos entre sí a través de channels
channels

Primitiva de comunicación nativa de Go que permite enviar y recibir valores entre goroutines de forma segura y sincronizada, sin necesidad de locks ni mutexes.

. Un channel es una primitiva del lenguaje (no una librería externa) que permite enviar y recibir valores de forma segura entre goroutines.
Hay dos tipos de channels. Un unbuffered channel sincroniza al sender y al receiver: el que envía se bloquea hasta que alguien lee, y el que lee se bloquea hasta que alguien envía. Es como pasarse una pelota de mano en mano.
go
ch := make(chan string) // unbuffered

go func() {
    ch <- "hello" // blocks until someone reads
}()

msg := <-ch // blocks until someone sends
fmt.Println(msg) // "hello"
Un buffered channel permite encolar N valores antes de bloquear al sender. Es útil cuando el productor y el consumidor trabajan a velocidades diferentes:
go
ch := make(chan string, 3) // buffer of 3

ch <- "first"  // doesn't block
ch <- "second" // doesn't block
ch <- "third"  // doesn't block
// ch <- "fourth" would block here until someone reads

fmt.Println(<-ch) // "first"
En Kotlin, para comunicar datos entre coroutines de forma similar necesitas usar Channel de kotlinx.coroutines (una librería, no parte del lenguaje) o Flow. Y para proteger estado compartido, necesitas Mutex
Mutex

Mutual Exclusion. Primitiva de sincronización que garantiza que solo un hilo o coroutine pueda acceder a un recurso compartido a la vez. Previene race conditions pero agrega complejidad al código.

. En Go, los channels reemplazan la mayoría de esos casos de forma nativa y con menos código.
Pero donde los channels se vuelven realmente poderosos es con select: un statement que permite esperar en múltiples channels simultáneamente y actuar sobre el primero que tenga datos. Funciona como un switch, pero para comunicación concurrente:
go
func fetchWithTimeout(url string) (string, error) {
    resultCh := make(chan string)
    errCh := make(chan error)

    go func() {
        resp, err := http.Get(url)
        if err != nil {
            errCh <- err
            return
        }
        defer resp.Body.Close()
        body, _ := io.ReadAll(resp.Body)
        resultCh <- string(body)
    }()

    select {
    case result := <-resultCh:
        return result, nil
    case err := <-errCh:
        return "", err
    case <-time.After(3 * time.Second):
        return "", fmt.Errorf("timeout after 3s")
    }
}
Ese select espera tres cosas a la vez: el resultado, un error, o un timeout. El primero que llegue gana. Este patrón es la base para construir cosas como gateways, donde necesitas llamar a múltiples servicios concurrentemente y manejar timeouts de forma elegante.

select es más que un switch

El select de Go no tiene equivalente directo en Kotlin. Es la forma idiomática de esperar en múltiples channels, implementar timeouts, cancelaciones y circuit breakers. Si necesitas coordinar múltiples fuentes de datos concurrentes, select es tu herramienta.

Patrones: fan-out/fan-in y el gateway

Fan-out
Fan-out

Patrón de concurrencia donde múltiples workers (goroutines) leen del mismo canal de entrada para distribuir y procesar trabajo en paralelo.

es cuando múltiples goroutines leen del mismo channel para distribuir trabajo. Fan-in
Fan-in

Patrón de concurrencia donde múltiples workers escriben sus resultados en un mismo canal de salida, consolidando el trabajo paralelo en un solo flujo.

es cuando múltiples goroutines escriben a un mismo channel para consolidar resultados. Juntos, forman el patrón más natural de Go para procesar trabajo concurrente.
Donde este patrón brilla de verdad es en un API gateway
API gateway

Servicio que actúa como punto de entrada único para múltiples backends. Recibe requests de los clientes, los enruta a los servicios internos correspondientes, y consolida las respuestas.

: un servicio que recibe un request, consulta múltiples backends en paralelo, y devuelve una respuesta consolidada. En Go, esto se escribe de forma directa con goroutines y channels:
go
package main

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

type GatewayResponse struct {
    User     UserData     `json:"user"`
    Orders   []Order      `json:"orders"`
    Inventory []Product   `json:"inventory"`
}

func gatewayHandler(w http.ResponseWriter, r *http.Request) {
    userCh := make(chan UserData)
    ordersCh := make(chan []Order)
    inventoryCh := make(chan []Product)

    // Fan-out: launch 3 goroutines to different backends
    go func() { userCh <- fetchUser(r.URL.Query().Get("userId")) }()
    go func() { ordersCh <- fetchOrders(r.URL.Query().Get("userId")) }()
    go func() { inventoryCh <- fetchInventory() }()

    // Fan-in: collect all results
    response := GatewayResponse{
        User:      <-userCh,
        Orders:    <-ordersCh,
        Inventory: <-inventoryCh,
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(response)
}

func main() {
    http.HandleFunc("/api/dashboard", gatewayHandler)
    http.ListenAndServe(":8080", nil)
}
Cada llamada a un backend corre en su propia goroutine. Las tres se ejecutan al mismo tiempo, y el handler las espera con <-channel. Si cada backend tarda 100ms, el request total tarda unos 100ms, no 300ms. Y como las goroutines cuestan 2KB, puedes tener miles de requests haciendo esto simultáneamente sin que el servicio se inmute.

Latencia concurrente, no secuencial

Con goroutines y channels, la latencia de tu endpoint es la del backend más lento, no la suma de todos. Tres llamadas de 100ms cada una se resuelven en 100ms, no en 300ms.

Fan-out/fan-in: API Gateway

HTTP RequestGatewayfan-outfetchUser()goroutine + ch1fetchOrders()goroutine + ch2fetchInventory()goroutine + ch3fan-inJSON Response
En Kotlin puedes lograr algo similar con async y awaitAll dentro de un coroutineScope. Pero hay una diferencia sutil: en Go, los channels hacen que la comunicación sea explícita y visible en el código. No estás retornando futures y esperando resultados: estás pasando datos por un canal, y eso hace que el flujo de datos del sistema sea más fácil de razonar.

Lo que Go resuelve sin que se lo pidas

La concurrencia fue lo que me enganchó, pero hay varias cosas más que hicieron que Go se quedara como mi primera opción.
Binarios estáticos
Binarios estáticos

Archivos ejecutables que incluyen todas sus dependencias compiladas dentro del mismo archivo. No necesitan librerías externas ni un runtime instalado en la máquina destino para funcionar.

. go build produce un único archivo ejecutable. Sin JVM, sin dependencias de runtime, sin Docker multi-stage complejo. Lo copias al servidor y funciona. Esto es especialmente poderoso para CLIs y herramientas de infraestructura donde quieres distribuir un solo archivo que funcione en cualquier máquina.
Tiempos de compilación y arranque. Go compila proyectos completos en segundos y los servicios arrancan en milisegundos. Comparado con un Spring Boot típico que puede tardar 10-30 segundos solo en arrancar, la diferencia se siente en cada ciclo de desarrollo y en cada deploy. Y cuando trabajas con autoscaling
autoscaling

Mecanismo que ajusta automáticamente la cantidad de instancias de un servicio según la demanda. Cuando el tráfico sube, se crean nuevas instancias; cuando baja, se eliminan las sobrantes.

, esto se vuelve crítico: cada nueva instancia que el orquestador levanta necesita estar lista para recibir tráfico lo antes posible. Un servicio en Go está listo en milisegundos; uno en Spring Boot puede tardar medio minuto. Esa diferencia define qué tan rápido tu sistema responde a picos de demanda.
Bajo consumo de memoria. Un microservicio en Go típicamente consume entre 10 y 20MB de RAM. Un Spring Boot equivalente fácilmente consume entre 200 y 500MB. Cuando corres decenas de servicios, esa diferencia se traduce directamente en costos de infraestructura.
Tooling incluido. go test, go fmt, go vet, go mod vienen con la instalación del lenguaje. No hay debates sobre formateo de código (todos usan gofmt), no hay que configurar linters externos para lo básico, y el sistema de módulos funciona sin sorpresas.
Ecosistema cloud. Docker, Kubernetes, Terraform, Prometheus, Grafana agent: todos están escritos en Go. Cuando trabajas en infraestructura y herramientas de plataforma, Go no es solo una opción: es el lenguaje nativo del ecosistema.

Go en números vs Spring Boot

Arranque en milisegundos vs 10-30 segundos. Consumo de 10-20MB de RAM vs 200-500MB. Un solo binario vs JVM + dependencias. Estas diferencias se multiplican cuando operas miles de microservicios en producción.

Cuándo sigo eligiendo Kotlin y cuándo elijo Go

Nada de esto significa que Kotlin sea un mal lenguaje. Sigue siendo excelente para lo que hace bien, y hay contextos donde lo sigo eligiendo:
Kotlin cuando necesito el ecosistema completo de Spring Boot: seguridad con Spring Security, persistencia con JPA, integraciones enterprise que ya existen en el mundo JVM. También cuando trabajo con equipos que ya están en Kotlin y la productividad del equipo pesa más que mis preferencias individuales. Y cuando el proyecto se beneficia de abstracciones más ricas como sealed classes
sealed classes

Clases restringidas de Kotlin que limitan las subclases posibles a un conjunto conocido en tiempo de compilación. Permiten modelar estados finitos y hacer pattern matching exhaustivo con when.

, extension functions o DSLs
DSLs

Domain-Specific Languages. Lenguajes pequeños diseñados para un dominio específico. Kotlin permite crearlos gracias a sus extension functions, lambdas con receiver y convenciones de sintaxis.

.
Go cuando construyo microservicios livianos, CLIs, herramientas de infraestructura, gateways, workers, o cualquier cosa donde el deploy simple y el bajo consumo de recursos importan. Cuando quiero que el servicio arranque en milisegundos, consuma pocos MB de RAM y produzca un binario que se despliega en cualquier parte.
Al final, el mejor lenguaje es el que resuelve tu problema con la menor fricción. Para mí, eso fue aceptar que Kotlin ya no era la respuesta para todo, y que Go tenía las respuestas que no sabía que estaba buscando.

Posts que podrian interesarte