Go vs Kotlin para backend: dos formas muy distintas de construir servicios
Comparación entre Go y Kotlin para backend desde la experiencia real. Spring Boot/Ktor frente a Go, tipado, despliegue y productividad.

Llevo años escribiendo Kotlin. Es el lenguaje en el que pienso cuando diseño un servicio, el que uso cuando quiero que un dominio complejo quede bien modelado, y el que elegiría si tuviera que mantener un monolito durante una década. Cuando empecé a aprender Go, la sensación fue extraña: como volver a un mundo donde muchas de las cosas que doy por supuestas simplemente no existen. Y lo sorprendente es que eso no siempre es malo.
Esta comparación no es neutral. Kotlin es mi herramienta principal, y eso significa que conozco sus virtudes, sus trampas y los momentos en que te salva la vida. Pero también significa que puedo ser honesto sobre cuándo Go ofrece algo que Kotlin no da, o no da tan fácilmente.
Filosofía de diseño: expresividad vs minimalismo intencional
La diferencia fundamental entre Go y Kotlin no está en la sintaxis ni en el rendimiento. Está en algo más sutil: en lo que cada lenguaje decide no darte. Y esa decisión dice más sobre la filosofía del lenguaje que cualquier feature list.
Kotlin hereda la tradición de lenguajes expresivos. Quiere que escribas menos, que el código se lea casi como prosa, que el compilador detecte tantos errores como sea posible antes de ejecutar nada. Te ofrece null safety, extension functions, coroutines, sealed classes, data classes, delegated properties, smart casts… La lista es larga y cada feature tiene su razón de ser.
Go toma la dirección contraria. Rob Pike y el equipo de Google diseñaron Go con una premisa clara: la simplicidad no es ausencia de features, es una feature en sí misma. No hay herencia, no hay excepciones, no hay generics con la potencia de Kotlin (aunque Go 1.18 añadió generics básicos), no hay extension functions, no hay operator overloading. Y eso es deliberado.
Go no quiere que escribas código elegante. Quiere que cualquier persona de tu equipo pueda leer y entender cualquier archivo del proyecto en treinta segundos.
Cuando vienes de Kotlin, eso se siente restrictivo. Casi frustrante, diría. Pero cuando mantienes un servicio escrito por otra persona en Go, empiezas a entender el punto. No porque Go sea “mejor” que Kotlin. Sino porque resuelve el problema de la legibilidad de una forma que Kotlin, por diseño, delega en la disciplina del equipo.
Frameworks de backend: Spring Boot/Ktor vs Go stdlib/Gin
Pero dejemos la filosofía y vayamos a lo concreto, que es donde estas diferencias se sienten de verdad.
El ecosistema Kotlin
En Kotlin para backend tienes dos caminos principales:
- Spring Boot con Kotlin: el estándar de facto en el mundo JVM corporativo. Inyección de dependencias, autoconfiguración, un ecosistema de starters enorme, integración con todo lo imaginable.
- Ktor: framework nativo de Kotlin de JetBrains. Más ligero, basado en coroutines, con un modelo de plugins. Muy agradable de usar, pero con un ecosistema más reducido.
Un endpoint típico en Spring Boot con Kotlin:
@RestController
@RequestMapping("/api/users")
class UserController(
private val userService: UserService
) {
@GetMapping("/{id}")
suspend fun getUser(@PathVariable id: Long): ResponseEntity<UserDto> {
val user = userService.findById(id)
?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(user.toDto())
}
@PostMapping
suspend fun createUser(@RequestBody @Valid request: CreateUserRequest): ResponseEntity<UserDto> {
val user = userService.create(request)
return ResponseEntity.status(HttpStatus.CREATED).body(user.toDto())
}
}Y el mismo en Ktor:
fun Route.userRoutes(userService: UserService) {
route("/api/users") {
get("/{id}") {
val id = call.parameters["id"]?.toLongOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest)
val user = userService.findById(id)
?: return@get call.respond(HttpStatusCode.NotFound)
call.respond(user.toDto())
}
post {
val request = call.receive<CreateUserRequest>()
val user = userService.create(request)
call.respond(HttpStatusCode.Created, user.toDto())
}
}
}El ecosistema Go
Go tiene una librería estándar potente para HTTP. No necesitas un framework para montar una API funcional. Pero en la práctica, la mayoría de proyectos usan algo como Gin, Chi o Echo para no reinventar el routing.
El mismo endpoint en Go con Gin:
func (h *UserHandler) SetupRoutes(r *gin.Engine) {
users := r.Group("/api/users")
{
users.GET("/:id", h.GetUser)
users.POST("", h.CreateUser)
}
}
func (h *UserHandler) GetUser(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
user, err := h.userService.FindByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusOK, toUserDTO(user))
}
func (h *UserHandler) CreateUser(c *gin.Context) {
var req CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.userService.Create(c.Request.Context(), &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
return
}
c.JSON(http.StatusCreated, toUserDTO(user))
}Y con la librería estándar de Go (sin framework):
mux := http.NewServeMux()
mux.HandleFunc("GET /api/users/{id}", h.GetUser)
mux.HandleFunc("POST /api/users", h.CreateUser)Comparación directa
| Aspecto | Kotlin (Spring Boot) | Kotlin (Ktor) | Go (Gin/stdlib) |
|---|---|---|---|
| Curva de aprendizaje | Alta (Spring es enorme) | Media | Baja |
| Productividad inicial | Media (mucha config) | Alta | Alta |
| Magia implícita | Mucha (DI, proxies, AOP) | Poca | Ninguna |
| Ecosistema de librerías | Enorme (todo el mundo JVM) | Creciente | Suficiente |
| Arranque en frío | Lento (JVM) | Lento (JVM) | Instantáneo |
| Tamaño del artefacto | Grande (~50-200 MB) | Medio (~30-80 MB) | Pequeño (~10-20 MB) |
En Spring Boot hay que saber mucho para entender qué está pasando realmente. En Go, lo que ves es lo que se ejecuta. Ktor está en un punto intermedio que me gusta bastante.
Sistema de tipos: dos mundos distintos
Aquí es donde tengo que ser honesto con mi sesgo. El sistema de tipos de Kotlin es uno de sus mayores argumentos, y es la parte de Kotlin que más echo de menos cuando escribo Go. Lo digo sin reservas: si tu dominio es complejo, Kotlin te deja modelarlo de una forma que Go simplemente no puede igualar.
Kotlin: tipos como documentación del dominio
sealed interface PaymentResult {
data class Success(val transactionId: String, val amount: Money) : PaymentResult
data class Declined(val reason: DeclineReason) : PaymentResult
data class Error(val exception: PaymentException) : PaymentResult
}
@JvmInline
value class Money(val cents: Long) {
init {
require(cents >= 0) { "Money cannot be negative" }
}
operator fun plus(other: Money) = Money(cents + other.cents)
}
enum class DeclineReason {
INSUFFICIENT_FUNDS, EXPIRED_CARD, FRAUD_SUSPECTED
}
// El when te obliga a manejar todos los casos
fun handlePayment(result: PaymentResult): String = when (result) {
is PaymentResult.Success -> "Pago ${result.transactionId} completado"
is PaymentResult.Declined -> "Rechazado: ${result.reason}"
is PaymentResult.Error -> "Error: ${result.exception.message}"
}Esto es expresividad real. El compilador te garantiza que manejas todos los casos. Los value classes evitan errores de tipo “pasar un Long donde iba otro Long”. Las sealed interfaces modelan estados finitos con datos asociados.
Go: tipos simples, composición explícita
type PaymentResult struct {
Status string
TransactionID string
Amount int64
DeclineReason string
Err error
}
func handlePayment(result PaymentResult) string {
switch result.Status {
case "success":
return fmt.Sprintf("Pago %s completado", result.TransactionID)
case "declined":
return fmt.Sprintf("Rechazado: %s", result.DeclineReason)
case "error":
return fmt.Sprintf("Error: %v", result.Err)
default:
return "Estado desconocido"
}
}El default en Go es revelador, y creo que resume bien la diferencia de filosofías. Kotlin te obliga a manejar todos los casos. Go no sabe cuáles son todos los casos, así que necesitas ese default defensivo. Si alguien añade un nuevo estado, el compilador de Kotlin te avisa en todos los sitios donde falta. En Go, el default se traga el caso silenciosamente. ¿Se puede argumentar que eso es un problema de disciplina y no de lenguaje? Sí. Pero la realidad es que los compiladores son mejores siendo disciplinados que los humanos.
En dominios complejos (finanzas, salud, logística), la capacidad de modelado de Kotlin no tiene precio. Si tu servicio es un CRUD con pocas reglas de negocio, la diferencia importa menos.
Concurrencia: coroutines vs goroutines
Esta es una de las comparaciones más interesantes, y la que más me ha hecho reflexionar. Porque ambos lenguajes resuelven el mismo problema de forma parecida conceptualmente, pero muy distinta en la práctica.
Kotlin coroutines
suspend fun fetchUserWithOrders(userId: Long): UserWithOrders {
return coroutineScope {
val userDeferred = async { userService.findById(userId) }
val ordersDeferred = async { orderService.findByUserId(userId) }
val user = userDeferred.await()
?: throw UserNotFoundException(userId)
val orders = ordersDeferred.await()
UserWithOrders(user, orders)
}
}
// Structured concurrency con timeout
suspend fun fetchWithTimeout(userId: Long): UserWithOrders {
return withTimeout(5.seconds) {
fetchUserWithOrders(userId)
}
}Goroutines en Go
func fetchUserWithOrders(ctx context.Context, userID int64) (*UserWithOrders, error) {
g, ctx := errgroup.WithContext(ctx)
var user *User
var orders []Order
g.Go(func() error {
var err error
user, err = userService.FindByID(ctx, userID)
return err
})
g.Go(func() error {
var err error
orders, err = orderService.FindByUserID(ctx, userID)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
return &UserWithOrders{User: user, Orders: orders}, nil
}Diferencias que importan
| Aspecto | Kotlin coroutines | Go goroutines |
|---|---|---|
| Concurrencia estructurada | Nativa (coroutineScope) | Con errgroup o manual |
| Cancelación | Cooperativa, propagada | Vía context.Context |
| Curva de aprendizaje | Media-alta (scopes, dispatchers) | Baja (pero fácil equivocarse) |
| Channels | Sí (Channel<T>) | Sí (parte del lenguaje) |
| Peso del runtime | Depende de JVM | Muy ligero |
| Leaks | Structured concurrency los previene | Fácil crear goroutine leaks |
Lo que más me gusta de las coroutines de Kotlin es la concurrencia estructurada: si el scope padre se cancela, todos los hijos se cancelan automáticamente. En Go tienes que ser muy disciplinado con el context y la propagación de errores, o acabas con goroutines colgadas que nadie cancela.
Pero las goroutines tienen una ventaja que no puedo ignorar: cualquier función puede lanzar una goroutine con go. No necesitas marcar funciones como suspend, no hay coloring problem, no necesitas entender dispatchers ni scopes. La barrera de entrada es mucho menor. ¿A costa de qué? De que es más fácil meter la pata sin que nadie te avise. Es un trade-off, no una victoria clara.
Manejo de errores: sealed classes vs if err != nil
Y aquí llegamos al punto que más me cuesta tratar con ecuanimidad. Esta es probablemente la diferencia más polarizante y la que más frustración genera cuando vienes de un lenguaje expresivo.
Kotlin: errores como tipos
sealed interface Result<out T> {
data class Ok<T>(val value: T) : Result<T>
data class Err(val error: AppError) : Result<Nothing>
}
sealed interface AppError {
data class NotFound(val entity: String, val id: String) : AppError
data class Validation(val field: String, val message: String) : AppError
data class Infrastructure(val cause: Throwable) : AppError
}
fun findUser(id: Long): Result<User> {
val user = userRepository.findById(id)
?: return Result.Err(AppError.NotFound("User", id.toString()))
return Result.Ok(user)
}
// En el handler
when (val result = findUser(id)) {
is Result.Ok -> respond(result.value)
is Result.Err -> when (result.error) {
is AppError.NotFound -> respond(HttpStatusCode.NotFound)
is AppError.Validation -> respond(HttpStatusCode.BadRequest)
is AppError.Infrastructure -> respond(HttpStatusCode.InternalServerError)
}
}Go: if err != nil, y punto
func (s *UserService) FindUser(ctx context.Context, id int64) (*User, error) {
user, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("finding user %d: %w", id, err)
}
if user == nil {
return nil, ErrNotFound
}
return user, nil
}
// En el handler
user, err := userService.FindUser(ctx, id)
if err != nil {
if errors.Is(err, ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
c.JSON(http.StatusOK, toDTO(user))Voy a ser directo: el manejo de errores de Go es verboso y repetitivo. No hay forma elegante de decirlo. Tras escribir tu función número cien con cinco bloques if err != nil, echas de menos profundamente el when exhaustivo de Kotlin, la propagación automática de errores, o incluso las excepciones de Java. Técnicamente no estás haciendo nada mal, pero la sensación es de estar escribiendo boilerplate.
Pero hay un argumento a favor de Go que es difícil de ignorar: nunca hay un error oculto. Cada punto de fallo es visible. No hay excepciones que vuelen por el stack sin que nadie las atrape. No hay un runCatching que se trague un error crítico porque alguien no pensó en ese caso.
La verbosidad de Go en el manejo de errores es molesta. Pero forzarte a decidir qué hacer en cada punto de fallo produce código más robusto, aunque no lo parezca a primera vista.
Despliegue: JVM vs binario estático
Cambiando de tercio, esta es la sección donde Go gana de forma categórica. Y aquí sí que no hay mucho que matizar.
Kotlin sobre JVM
FROM eclipse-temurin:21-jre-alpine
COPY build/libs/app.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app.jar"]- Necesitas un JRE en la imagen (o GraalVM para native).
- El arranque en frío de Spring Boot puede ser de 3 a 15 segundos.
- El consumo de memoria base ronda los 150-400 MB.
- La imagen Docker pesa entre 150-300 MB.
Go
FROM scratch
COPY app /app
EXPOSE 8080
ENTRYPOINT ["/app"]Sí, FROM scratch. Un binario Go estáticamente compilado no necesita absolutamente nada. Ni sistema operativo base, ni runtime, ni librerías compartidas.
- Arranque en milisegundos.
- Consumo de memoria base de 5-20 MB.
- Imagen Docker de 10-20 MB.
| Aspecto | Kotlin (JVM) | Kotlin (GraalVM native) | Go |
|---|---|---|---|
| Tiempo de arranque | 3-15 s | 0.1-0.5 s | < 50 ms |
| Memoria base | 150-400 MB | 50-100 MB | 5-20 MB |
| Imagen Docker | 150-300 MB | 50-100 MB | 10-20 MB |
| Compilación | Rápida | Muy lenta (minutos) | Rápida |
| Debugging en producción | Bueno (JVM tools) | Limitado | Bueno (pprof, delve) |
Si trabajas con Kubernetes y necesitas escalar rápidamente, la diferencia entre 10 segundos de arranque y 50 milisegundos es la diferencia entre un autoscaling que funciona y uno que llega tarde.
GraalVM native image mejora bastante la situación para Kotlin/Spring Boot, pero la compilación es lenta, hay limitaciones con reflection (que Spring usa masivamente), y el debugging se complica. He probado GraalVM en proyectos reales y la experiencia es… desigual. Es una solución viable pero con trade-offs importantes que conviene experimentar antes de apostar fuerte.
Legibilidad y mantenimiento: bonito vs predecible
Aquí entra mi experiencia personal más directa, y la parte donde más me he autocorregido con el tiempo.
El código Kotlin puede ser precioso. Extension functions, DSLs, operadores personalizados, funciones de alto orden… puedes escribir código que se lee como un cuento. Y confieso que eso me encanta. Pero ese poder tiene un coste que al principio no quería ver: un desarrollador junior o alguien que no conoce bien Kotlin puede perderse leyendo un builder DSL complejo o una cadena de extension functions encadenadas con receivers implícitos.
// Kotlin idiomático y expresivo
val report = users
.filter { it.isActive && it.registeredAfter(cutoffDate) }
.groupBy { it.department }
.mapValues { (_, users) ->
users.sumOf { it.totalPurchases }
}
.toSortedMap()
.also { log.info("Report generated for ${it.size} departments") }El equivalente en Go:
// Go explícito y paso a paso
report := make(map[string]int64)
for _, user := range users {
if !user.IsActive || user.RegisteredAt.Before(cutoffDate) {
continue
}
report[user.Department] += user.TotalPurchases
}
keys := make([]string, 0, len(report))
for k := range report {
keys = append(keys, k)
}
sort.Strings(keys)
log.Printf("Report generated for %d departments", len(report))El de Kotlin es más corto y más declarativo. El de Go es más largo pero absolutamente explícito: ves cada paso, cada decisión, cada iteración. No hay magia. No hay funciones de extensión que tengas que ir a buscar. No hay lambdas con receivers implícitos. ¿Cuál es “mejor”? Depende de a quién le preguntes, y sobre todo de quién va a mantener ese código dentro de dos años.
En un equipo donde todos dominan Kotlin, el primer estilo es claramente mejor. En un equipo heterogéneo, o en un proyecto open source donde contribuyen personas con distintos niveles, Go tiene una ventaja real: todo el mundo lee Go igual, porque no hay muchas formas de escribirlo.
Go es el lenguaje donde el código que escribes en tu primer mes se parece mucho al que escribes en tu tercer año. Kotlin es el lenguaje donde cada año descubres una forma mejor de hacer las cosas. Ambos tienen valor.
Ecosistema y librerías
Y esto nos lleva al ecosistema, que es donde la conversación se vuelve más pragmática. Kotlin tiene acceso a todo el ecosistema JVM. Eso son décadas de librerías maduras, probadas en producción por millones de aplicaciones:
- Hibernate/Exposed para ORM
- Jackson/kotlinx.serialization para JSON
- Spring Security para autenticación
- Apache Kafka clients, Elasticsearch, Redis…
La lista es infinita. Si existe una librería Java, funciona en Kotlin.
Go tiene un ecosistema más joven pero sorprendentemente completo para backend:
- GORM/sqlx/pgx para bases de datos
- encoding/json en stdlib (y opciones como sonic para rendimiento)
- Gin/Chi/Echo para HTTP
- Clientes oficiales de Google Cloud, AWS SDK, etc.
Donde Go flaquea es en dominios enterprise complejos. No hay un equivalente a Spring Security con la misma profundidad. No hay un ORM tan completo como Hibernate --- y muchos en la comunidad Go argumentan que eso es una virtud, lo cual es un debate interesante pero que no te resuelve el problema cuando necesitas un ORM de verdad. Las librerías de validación son más básicas.
| Dominio | Kotlin/JVM | Go |
|---|---|---|
| HTTP/API | Excelente | Excelente |
| Bases de datos | Excelente (Hibernate, Exposed) | Bueno (sqlx, pgx) |
| Mensajería (Kafka, RabbitMQ) | Excelente | Bueno |
| Observabilidad | Excelente (Micrometer, etc.) | Buena (OpenTelemetry) |
| Machine Learning | Buena (DL4J, interop Python) | Básica |
| CLI tools | Limitado | Excelente |
| Infraestructura (Docker, K8s) | No es su fuerte | Nativo |
Cuándo Kotlin es la mejor elección
Después de trabajar con ambos, creo que tengo bastante claro cuándo Kotlin gana. Aunque reconozco que mi sesgo hacia Kotlin puede influir aquí:
Dominios complejos con lógica de negocio rica. Si tu servicio gestiona estados, reglas de negocio con muchas variantes, workflows complejos… las sealed classes, el sistema de tipos y la expresividad de Kotlin te van a ahorrar bugs y hacer el código mucho más mantenible.
Equipos con experiencia JVM. Si tu equipo viene de Java, Kotlin es una mejora directa sin cambiar de ecosistema. Spring Boot sigue funcionando, las librerías que conoces siguen ahí, pero escribes menos y con más seguridad.
Proyectos que necesitan el ecosistema JVM. Si dependes de librerías específicas de Java (ciertos clientes enterprise, frameworks de reporting, integración con sistemas legacy), Kotlin sobre JVM es la opción obvia.
Android y multiplataforma. Kotlin Multiplatform permite compartir lógica entre backend, Android, iOS y web. Go no juega en este terreno.
Cuando la corrección del modelo de datos es crítica. En Spring Boot con Kotlin, poder modelar tu dominio con sealed interfaces y value classes reduce una categoría entera de bugs.
Cuándo Go es la mejor elección
Pero la pregunta interesante es otra: ¿cuándo Go ofrece algo que Kotlin no da tan fácilmente? Y la respuesta me sorprendió más de lo que esperaba:
Microservicios simples y APIs REST sin lógica compleja. Si tu servicio recibe peticiones, consulta una base de datos y devuelve JSON, Go te deja hacerlo con un consumo mínimo de recursos y un despliegue trivial. Para APIs REST sencillas, Go es difícil de superar.
Infraestructura y herramientas de sistema. Docker, Kubernetes, Terraform, Prometheus… están escritos en Go por buenas razones. Si construyes herramientas para DevOps, Go es el estándar de facto.
CLIs. Un binario estático que funciona en cualquier plataforma, sin dependencias. Go es imbatible para esto.
Cuando el rendimiento operativo importa más que la expresividad. En entornos donde necesitas arranque rápido, bajo consumo de memoria y despliegues ligeros (edge computing, funciones serverless con cold starts frecuentes), Go gana claramente.
Equipos heterogéneos o con alta rotación. La simplicidad de Go reduce el tiempo de onboarding. Cualquier desarrollador puede ser productivo en Go en una o dos semanas. Kotlin idiomático requiere más tiempo para dominarlo.
Cuando quieres una arquitectura limpia sin magia. Go te fuerza a ser explícito con las dependencias, a pasar todo por parámetro, a no depender de inyección automática. Para algunos proyectos, esa rigidez es exactamente lo que necesitas.
¿Se pueden usar ambos? Sí, y es una estrategia válida
En el mundo real, la pregunta “Go o Kotlin” no siempre tiene una sola respuesta. Y creo que la madurez está en aceptar eso sin frustrarte. Conozco equipos (y yo mismo lo planteo para ciertos proyectos) que usan ambos:
- Kotlin para el servicio de dominio principal, donde la lógica de negocio es compleja y el sistema de tipos de Kotlin protege contra errores sutiles.
- Go para servicios auxiliares: proxies, workers de procesamiento de colas, herramientas de infraestructura, APIs gateway.
Esto no es overengineering si se hace con criterio. Cada lenguaje juega donde es más fuerte. Lo importante es que el equipo tenga competencia en ambos y que haya convenciones claras sobre cuándo usar cada uno.
┌─────────────────────────────────────────────────┐
│ Arquitectura mixta │
├────────────────────┬────────────────────────────┤
│ Go │ Kotlin │
│ ────────────── │ ──────────────────── │
│ API Gateway │ Servicio de pagos │
│ Worker de colas │ Motor de reglas │
│ CLI de deploy │ Servicio de usuarios │
│ Proxy de métricas │ Orquestador de workflows │
└────────────────────┴────────────────────────────┘Lo que he aprendido eligiendo entre ambos
No voy a fingir equidistancia, porque sería deshonesto. Kotlin sigue siendo mi lenguaje preferido para backend. Su sistema de tipos, su expresividad y el ecosistema JVM lo hacen imbatible para proyectos con complejidad real. Cuando un dominio tiene muchos estados, muchas reglas y necesita evolucionar durante años, Kotlin me da confianza de que el código va a aguantar. Para dominios complejos, modelado de datos rico, o cuando ya tienes un equipo con experiencia JVM, Kotlin es la elección que me sale sola.
Pero Go me ha enseñado algo que no esperaba: el valor de lo simple. Me ha obligado a cuestionar si realmente necesito esa abstracción, ese DSL, ese patrón sofisticado. Y siendo honestos, más veces de las que me gustaría admitir la respuesta era no. Cuando la respuesta es sí, Kotlin brilla. Pero cuando es no, Go te recompensa con un servicio que arranca en milisegundos, consume nada, y que cualquiera puede mantener. Para microservicios ligeros, CLIs, herramientas de infra, o cualquier cosa donde el arranque rápido y el bajo consumo importen, Go me ha demostrado que menos abstracción puede ser más productividad.
Si estás empezando con Go viniendo de Kotlin o Java, te recomiendo revisar la comparación Go vs Java que entra más en detalle sobre las diferencias con el mundo JVM. Y si quieres ver cómo es construir algo real en Go, echa un vistazo a cómo montar una API REST desde cero. La mejor herramienta depende del problema. Y la mejor señal de madurez como ingeniero es no enamorarte de ninguna.


