Variables de entorno en Kotlin: leer, valores por omisión y validar la configuración

El puerto, la ruta de la base de datos o una clave de API no deberían ir escritos en el código. Las variables de entorno los separan del programa, y Kotlin te ayuda a leerlas bien: null si faltan, valores por omisión con ?: y conversiones que no explotan.

Por qué variables de entorno

El puerto, la ruta de la base de datos o una clave de API cambian entre tu computadora, el servidor de pruebas y producción. Si van escritos en el código, hay que recompilar para cambiarlos, y las claves acaban en el repositorio. Las variables de entorno las pone quien ejecuta el programa. En Linux y macOS:

export APP_NOMBRE=Tienda
export APP_PUERTO=8080
export APP_BD_RUTA=/datos/tienda.db
java -jar app.jar

En PowerShell de Windows se escribe $env:APP_PUERTO = "8080". Todos los ejemplos de esta página se ejecutaron con esas tres variables definidas.

Leer una variable

fun main() {
    val nombre: String? = System.getenv("APP_NOMBRE")
    val noExiste: String? = System.getenv("APP_NO_EXISTE")
    println("APP_NOMBRE = $nombre")
    println("APP_NO_EXISTE = $noExiste")
    println("Usuario del sistema: ${System.getenv("USER") ?: "(sin USER)"}")
}
salidaAPP_NOMBRE = Tienda
APP_NO_EXISTE = null
Usuario del sistema: (sin USER)

System.getenv(nombre) devuelve el valor como texto, o null si la variable no existe. Por eso aquí la guardamos en un String?: es lo honesto, porque nadie te garantiza que esté definida. Fíjate en USER: en tu terminal casi siempre existe, pero en un contenedor o un servicio de systemd puede no estar.

Valores por omisión con ?:

fun main() {
    val nombre = System.getenv("APP_NOMBRE") ?: "Mi app"
    val modo = System.getenv("APP_MODO") ?: "desarrollo"
    val puerto = System.getenv("APP_PUERTO")?.toIntOrNull() ?: 8000
    println("$nombre en modo $modo, puerto $puerto")
}
salidaTienda en modo desarrollo, puerto 8080

El operador Elvis ?: da un valor cuando lo de la izquierda es null. Y como todo llega como texto, para un número se encadena toIntOrNull(), que devuelve null si el texto no es un número válido, en lugar de lanzar una excepción como toInt().

Cargar y validar toda la configuración

En un programa real conviene leer todo en un solo sitio, al arrancar, y fallar enseguida con un mensaje claro si algo está mal. Es mucho mejor que descubrirlo a medianoche, cuando el programa intenta abrir una base de datos que no existe:

import kotlin.system.exitProcess

data class Config(val nombre: String, val puerto: Int, val rutaBd: String, val depurar: Boolean)

fun cargarConfig(env: Map<String, String> = System.getenv()): Config {
    val errores = mutableListOf<String>()

    val puertoTexto = env["APP_PUERTO"] ?: "8000"
    val puerto = puertoTexto.toIntOrNull()
    if (puerto == null || puerto !in 1..65535) errores += "APP_PUERTO debe ser un número entre 1 y 65535 (vale «$puertoTexto»)"

    val rutaBd = env["APP_BD_RUTA"]
    if (rutaBd.isNullOrBlank()) errores += "falta APP_BD_RUTA"

    if (errores.isNotEmpty()) {
        System.err.println("Configuración no válida:")
        errores.forEach { System.err.println("  - $it") }
        exitProcess(2)
    }
    return Config(
        nombre = env["APP_NOMBRE"] ?: "Mi app",
        puerto = puerto!!,
        rutaBd = rutaBd!!,
        depurar = env["APP_DEPURAR"].toBoolean(),
    )
}

fun main() {
    val config = cargarConfig()
    println(config)
}
salidaConfig(nombre=Tienda, puerto=8080, rutaBd=/datos/tienda.db, depurar=false)
código de salida: 0
--- con APP_PUERTO=abc y sin APP_BD_RUTA
Configuración no válida:
  - APP_PUERTO debe ser un número entre 1 y 65535 (vale «abc»)
  - falta APP_BD_RUTA
código de salida: 2

Con la configuración correcta, el programa imprime la data class. Con un puerto que no es número y sin la ruta de la base, junta todos los errores (no solo el primero), los escribe en la salida de errores y termina con código 2, que los scripts y systemd interpretan como fallo. cargarConfig recibe el mapa como parámetro, con System.getenv() por omisión: en las pruebas le pasas un mapOf(...) y no tienes que tocar el entorno de verdad.

Un detalle: "cualquier cosa".toBoolean() da false salvo que el texto sea «true» (sin importar mayúsculas), y en Kotlin funciona incluso sobre un String? nulo. Así, APP_DEPURAR sin definir equivale a false.

Todas las de tu aplicación

fun main() {
    System.getenv()
        .filterKeys { it.startsWith("APP_") }
        .toSortedMap()
        .forEach { (clave, valor) -> println("$clave=$valor") }
}
salidaAPP_BD_RUTA=/datos/tienda.db
APP_NOMBRE=Tienda
APP_PUERTO=8080

Sin argumentos, System.getenv() devuelve un mapa con todas. Un prefijo común (APP_) evita chocar con las del sistema y permite listarlas así. Nunca imprimas así los secretos en un registro.

El error típico: olvidar que puede faltar

fun main() {
    val token = System.getenv("APP_TOKEN")
    println("El token tiene ${token.length} caracteres")
}
salida con errorException in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "token" is null
    at PlataformaKt.main(plataforma.kt:3)

Esto compila sin un aviso. System.getenv está escrito en Java, que no dice si puede devolver null, así que Kotlin le da un tipo de plataforma (String!): te deja usarlo como String y como String?, y la responsabilidad es tuya. Si la variable no existe, el resultado es el NullPointerException de toda la vida. La costumbre que te protege es la de los ejemplos anteriores: declara el tipo como String? o usa ?: en cuanto lo lees, y el compilador vuelve a cuidarte. Lo explicamos a fondo en la lección de null safety.

  • ParaEscribe
  • Leer unaSystem.getenv("APP_PUERTO") (puede ser null)
  • Con valor por omisiónSystem.getenv("X") ?: "valor"
  • Como número?.toIntOrNull() ?: 8000
  • Como booleanoSystem.getenv("X").toBoolean()
  • TodasSystem.getenv() (un Map)
  • Salir con errorexitProcess(2) tras explicar el problema en System.err

No necesitas bibliotecas: kotlinc config.kt -include-runtime -d config.jar y java -jar config.jar. En Android, en cambio, no se usan variables de entorno: la configuración va en el build.gradle.kts (buildConfigField) o en recursos.

Todo el código de esta página se compiló con Kotlin 2.4.21 y se ejecutó en la JVM de Temurin 25.0.5 en un contenedor limpio y sin red antes de publicarla; las salidas y los mensajes del compilador están copiados de esa ejecución.