Saltar al contenido principal

Tareas como abstracciones de acciones repetibles

Metadatos de la lección

Autoría:
Ignacio Slater-Muñoz
Última actualización:
15 de mayo de 2026

Cambios recientes:

  • e8f2c4c · 15 de mayo de 2026 · ✨ feat(notes): Update description for task abstraction lesson in NotesLayout ( GitLab / GitHub )
  • 2008386 · 14 de mayo de 2026 · 🌟 feat(lessons): Add Kotlin conventions reference to tasks-as-abstractions lesson ( GitLab / GitHub )
  • d7da7b4 · 14 de mayo de 2026 · 🚀 lesson(scripting): Expand on tasks as abstractions ( GitLab / GitHub )

Abstract

La lección anterior mostró cómo un script de apoyo puede tener un contrato operativo: recibe entradas, asume ciertas condiciones, produce una salida observable y permite decidir si la ejecución fue exitosa. En esta lección damos el siguiente paso: dividir ese contrato en tareas nombradas, repetibles y verificables.

A partir de un script .main.kts, construiremos una abstracción común con una interfaz Task , un contexto de ejecución y un resultado explícito. Luego veremos cómo un despachador puede seleccionar una tarea por nombre y ejecutarla sin mezclar la lógica de cada tarea con la lógica de la línea de comandos.

El contrato operativo de una tarea

Tarea

Una tarea es una unidad de trabajo nombrada, repetible y verificable. No se define solo por el comando que ejecuta, sino por la intención que representa, los datos que necesita, las condiciones que asume, los efectos que produce y el criterio que permite decidir si terminó correctamente.

Para que una acción pueda tratarse como tarea, conviene hacer explícito su contrato operativo:

  • El nombre permite identificar o invocar el trabajo.
  • La intención explica por qué existe la tarea.
  • Las entradas indican qué datos, argumentos o archivos necesita.
  • Las precondiciones declaran qué debe ser cierto antes de ejecutar.
  • Los efectos o resultados describen qué produce, modifica o concluye.
  • La salida observable permite revisar qué ocurrió durante la ejecución.
  • El criterio de éxito o falla permite interpretar si la tarea terminó correctamente.

Esta separación es útil porque permite razonar sobre una tarea incluso antes de decidir si vivirá en un script, en una herramienta de línea de comandos o en un sistema de construcción.

Un script con varias tareas pequeñas

Para hacer concreta esta idea, usaremos un script real llamado library-tasks.main.kts. El objetivo no es mostrar una característica especial de Kotlin para definir tareas, sino representar explícitamente varias acciones repetibles dentro de un mismo artefacto ejecutable.

Desde la línea de comandos, el script recibe el nombre de la tarea como primer argumento. Por ejemplo:

API esperada
kotlin library-tasks.main.kts count-files .
kotlin library-tasks.main.kts summarize .

Cada comando expresa una intención distinta. count-files cuenta archivos en una carpeta, mientras summarize muestra un resumen breve del proyecto revisado. Más adelante, en el ejercicio, agregaremos una tercera tarea: list, encargada de mostrar las tareas disponibles.

La diferencia importante es que ya no pensamos el script como una sola operación concreta. Ahora el script actúa como un pequeño punto de entrada que despacha tareas nombradas. Cada tarea puede tener su propio propósito, entradas, precondiciones, salida observable y criterio de éxito o falla.

El punto central no es que Kotlin tenga una sintaxis especial para tareas. El punto es que podemos representar tareas explícitamente incluso antes de usar una herramienta especializada como un sistema de construcción.

Representar una tarea con una interfaz

Para que el script pueda tratar todas las tareas de la misma forma, necesitamos una abstracción común. En este caso, esa abstracción puede ser una interfaz sencilla: cada tarea tiene un nombre y una operación run() que ejecuta su trabajo.

Contrato mínimo para representar tareas
scripts/library-tasks.main.kts
interface Task {
    val name: String
    val description: String

    fun run(context: TaskContext): TaskResult
}

class TaskContext(
    val projectRoot: Path,
)

interface TaskResult {
    val message: String
    val exitCode: Int
}

class SuccessfulTaskResult(
    override val message: String,
) : TaskResult {
    override val exitCode: Int = 0
}

class FailedTaskResult(
    override val message: String,
) : TaskResult {
    override val exitCode: Int = 1
}

Detalles clave

La interfaz Task no sabe cómo se lista una tarea, cómo se cuentan archivos ni cómo se resume un proyecto. Solo declara qué debe ofrecer cualquier tarea para poder ser ejecutada por el script.

  • name permite identificar la tarea desde la línea de comandos.
  • description permite mostrar ayuda o listar las tareas disponibles.
  • TaskContext agrupa la información que la tarea necesita para ejecutarse.
  • TaskResult hace explícito si la ejecución terminó bien o con error.

Esta estructura evita que el script dependa de casos especiales para cada comando. Mientras una operación implemente Task , el despachador puede registrarla, encontrarla por nombre y ejecutarla usando el mismo flujo.

Por ejemplo, una tarea concreta puede implementar esa interfaz sin cambiar el resto del script:

Una tarea concreta
class CountFilesTask : Task {
    override val name: String = "count-files"
    override val description: String = "Counts files in the selected project folder."

    override fun run(context: TaskContext): TaskResult {
        val fileCount = context.projectRoot
            .toFile()
            .walkTopDown()
            .count { it.isFile }

        return SuccessfulTaskResult("Found $fileCount files.")
    }
}

Lo importante no es que esta implementación sea sofisticada, sino que la tarea ya tiene una forma reconocible: nombre, descripción, contexto de ejecución, resultado observable y código de salida. Esa forma común permite agregar nuevas tareas sin rediseñar el script completo.

Despachar una tarea por su nombre

Una vez que las tareas comparten la misma interfaz, el script puede registrarlas en una colección común. En vez de escribir un flujo distinto para cada comando, podemos asociar cada nombre con la tarea que debe ejecutarse.

Despacho mínimo de tareas
scripts/library-tasks.main.kts
// Otras tareas concretas pueden implementarse de la misma forma.
class SummarizeProjectTask : Task {
    override val name: String = "summarize"
    override val description: String = "Shows a brief summary of the selected project folder."

    override fun run(context: TaskContext): TaskResult {
        val projectRoot = context.projectRoot.toAbsolutePath().normalize()
        val fileCount = projectRoot
            .toFile()
            .walkTopDown()
            .count { it.isFile }

        return SuccessfulTaskResult("Project: $projectRoot${System.lineSeparator()}Files: $fileCount")
    }
}


// El despachador registra las tareas disponibles por nombre.
val tasks: Map<String, Task> = listOf(
    CountFilesTask(),
    SummarizeProjectTask(),
).associateBy { task -> task.name }

val taskName = args.firstOrNull()
val selectedTask = tasks[taskName]

val result = if (selectedTask == null) {
    FailedTaskResult("Unknown task: $taskName")
} else {
    val projectRoot = Path.of(args.getOrElse(1) { "." })
    val context = TaskContext(projectRoot)

    selectedTask.run(context)
}

println(result.message)
exitProcess(result.exitCode)

El despachador cumple una responsabilidad pequeña: conectar los argumentos de la línea de comandos con una tarea concreta. Para hacerlo, lee el nombre solicitado, busca la tarea correspondiente, construye un contexto de ejecución, ejecuta la tarea y transforma el resultado en salida observable y código de salida.

Detalles clave

El ejemplo usa funciones pequeñas de la biblioteca estándar para mantener el despachador compacto. Ninguna de ellas define el concepto de tarea; solo ayudan a conectar los argumentos recibidos con la tarea que corresponde ejecutar.

  • associateBy {} transforma la lista de tareas en un diccionario, usando el nombre de cada tarea como clave. Por eso tasks["summarize"] puede encontrar directamente la instancia de SummarizeProjectTask .
  • firstOrNull() obtiene el primer argumento de la línea de comandos sin lanzar una excepción si no existe. En este caso, ese primer argumento representa el nombre de la tarea solicitada.
  • getOrElse(1) { "." } intenta leer el segundo argumento (es decir, el elemento con índice 1). Si no fue entregado, usa "." como ruta por defecto, es decir, la carpeta actual.
  • exitProcess() termina el script con el código de salida producido por la tarea. Así, otras herramientas pueden distinguir una ejecución exitosa de una fallida.

¿Y new ?

Al leer estos ejemplos, puede llamarte la atención que Kotlin no use new para crear instancias (o puede que no te llame la atención, no te juzgo): CountFilesTask() se escribe con la misma forma general que una llamada a función. Por eso la convención de nombres no es solo estética: las clases se escriben en PascalCase, mientras que las funciones y propiedades se escriben en camelCase. Así, CountFilesTask() se lee como construcción de un objeto, mientras que countFiles() se lee como invocación de una función.

Esta convención también explica por qué la ausencia de new no debería volver ambiguo el código: la diferencia principal no está en una palabra clave, sino en nombrar consistentemente tipos y operaciones.

Discusión extra: ¿debería existir una palabra clave como new ?

Esta discusión no es necesaria para usar Kotlin, pero muestra una decisión interesante de diseño de lenguajes. Algunos lenguajes usan una palabra clave como new para hacer explícita la creación de objetos. Otros prefieren omitirla y confiar en la sintaxis del lenguaje, las convenciones de nombres y el contexto.

Quienes defienden una palabra clave como new suelen valorar que el código muestre de forma explícita que se está creando una instancia. Quienes prefieren omitirla suelen valorar una sintaxis más breve y una frontera menos rígida entre constructores, funciones de fábrica y otras formas de producir objetos.

Para esta lección, la conclusión práctica es más simple: si el lenguaje no exige new , las convenciones de nombres dejan de ser un detalle superficial. Ayudan a que el código sea legible y a distinguir visualmente entre invocar una función y crear una instancia.

Esta separación evita que el script mezcle dos decisiones distintas: qué tarea ejecutar y cómo se ejecuta cada tarea. El despachador solo decide la primera; la segunda queda encapsulada dentro de cada implementación de Task .

  • El nombre escrito en la línea de comandos identifica la tarea solicitada.
  • La tabla tasks permite buscar una implementación sin escribir casos especiales para cada comando.
  • El TaskContext agrupa los datos compartidos que la tarea necesita para ejecutarse.
  • El TaskResult concentra el mensaje final y el código de salida.

Un detalle importante es que los nombres de las tareas deberían ser únicos. Si dos tareas usan el mismo name , la tabla generada con associateBy() no puede representar ambas de forma distinguible. En un script pequeño basta con revisar esto manualmente; en una herramienta más completa, convendría validarlo explícitamente.

Lo importante es que agregar una nueva tarea no exige reescribir el flujo principal. Basta con crear otra clase que implemente Task y registrarla en la lista de tareas disponibles. Esa es la misma idea que luego crecerá en un sistema de construcción: muchas tareas nombradas, cada una con su propio contrato operativo, pero coordinadas por una herramienta común.

Ejercicio: Agregar una tarea para listar tareas disponibles

Requisitos

Extiende el script library-tasks.main.kts agregando una nueva tarea llamada list. La meta es comprobar que el diseño permite sumar una tarea nueva sin reescribir el flujo principal del despachador.

  • Crea una clase ListTasksTask que implemente la interfaz Task .
  • Define su name como "list" y su description como una descripción breve de la tarea.
  • Extiende TaskContext para que también incluya las tareas disponibles.
  • La tarea debe listar cada tarea disponible usando el formato "- ${task.name}: ${task.description}".
  • La ejecución debe retornar un SuccessfulTaskResult .
  • Registra la nueva tarea en el despachador para que pueda ejecutarse con: kotlin library-tasks.main.kts list.

Notas

La tarea list necesita conocer las tareas registradas, pero no debería construir esa colección ni depender de una referencia incompleta al despachador.

Una forma más segura es pasar la información disponible mediante TaskContext . Así, el despachador construye el contexto cuando ya conoce el mapa completo de tareas.

Uso esperado

Uso esperado
kotlin library-tasks.main.kts list

La salida debería tener una forma similar a esta:

Output
Available tasks:
- count-files: Counts files in the selected project folder.
- summarize: Shows a brief summary of the selected project folder.
- list: Lists the available tasks.

Hints

  • Evita pasar una función como { tasks.values } al constructor de ListTasksTask . Aunque puede funcionar, deja una dependencia fácil de usar mal.
  • En lugar de eso, agrega una propiedad a TaskContext , por ejemplo availableTasks: Collection<Task>.
  • Cuando el despachador construya el contexto, pásale tasks.values . En ese punto, el mapa de tareas ya está completo.
  • Dentro de ListTasksTask.run , usa context.availableTasks para construir la salida.
  • Usa joinToString(System.lineSeparator()) para crear una línea por cada tarea disponible.

Solución

Primero, el contexto de ejecución puede incluir tanto la ruta del proyecto como la colección de tareas disponibles:

Contexto de ejecución extendido
class TaskContext(
    val projectRoot: Path,
    val availableTasks: Collection<Task>,
)

Luego, ListTasksTask usa esa información desde el contexto. Su constructor no necesita recibir una referencia al mapa de tareas ni una función que lo consulte.

Implementación de la tarea
class ListTasksTask : Task {
    override val name: String = "list"
    override val description: String = "Lists the available tasks."

    override fun run(context: TaskContext): TaskResult {
        val taskList = context.availableTasks
            .joinToString(System.lineSeparator()) { task ->
                "- ${task.name}: ${task.description}"
            }

        return SuccessfulTaskResult(
            "Available tasks:${System.lineSeparator()}$taskList"
        )
    }
}

Finalmente, el despachador registra todas las tareas y construye el contexto al momento de ejecutar una de ellas. Como el contexto se crea después de construir el mapa, availableTasks contiene la colección completa.

Registro y ejecución
val tasks: Map<String, Task> = listOf(
    CountFilesTask(),
    SummarizeProjectTask(),
    ListTasksTask(),
).associateBy { task -> task.name }

val taskName = args.firstOrNull()
val selectedTask = tasks[taskName]

val result = if (selectedTask == null) {
    FailedTaskResult("Unknown task: $taskName")
} else {
    val projectRoot = Path.of(args.getOrElse(1) { "." })
    val context = TaskContext(
        projectRoot = projectRoot,
        availableTasks = tasks.values,
    )

    selectedTask.run(context)
}

println(result.message)
exitProcess(result.exitCode)

Alternativa tentadora: una función proveedora

Otra forma de implementar ListTasksTask sería pasarle una función que entregue las tareas disponibles. Esa idea parece cómoda porque permite escribir { tasks.values } y consultar el mapa solo cuando la tarea se ejecuta.

Versión con función proveedora
class ListTasksTask(
    private val tasks: () -> Collection<Task>,
) : Task {
    override val name: String = "list"
    override val description: String = "Lists the available tasks."

    override fun run(context: TaskContext): TaskResult {
        val taskList = tasks()
            .joinToString(System.lineSeparator()) { task ->
                "- ${task.name}: ${task.description}"
            }

        return SuccessfulTaskResult(
            "Available tasks:${System.lineSeparator()}$taskList"
        )
    }
}
Registro frágil
val tasks: Map<String, Task> = listOf(
    CountFilesTask(),
    SummarizeProjectTask(),
    ListTasksTask { tasks.values },
).associateBy { task -> task.name }

El problema es que el diseño depende de una regla implícita: la función no debe llamarse durante la construcción del objeto. Si alguien cambia la clase y consulta tasks() demasiado pronto, el script puede fallar con un NullPointerException .

Riesgo de NullPointerException
class ListTasksTask(
    private val tasks: () -> Collection<Task>,
) : Task {
    override val name: String = "list"
    override val description: String = "Lists the available tasks."

    // Fuerza la consulta durante la construcción del objeto.
    private val taskCount = tasks().size

    override fun run(context: TaskContext): TaskResult =
        SuccessfulTaskResult("Available tasks: $taskCount")
}

Esto es relevante en Kotlin porque su seguridad frente a nulos reduce muchos errores comunes, pero no vuelve el código automáticamente inmune a referencias consultadas en un momento inválido. Por eso preferimos pasar las tareas disponibles mediante TaskContext : el contexto se construye cuando el mapa ya está completo.

Conclusiones

Una tarea permite darle nombre y estructura a una acción repetible. En vez de pensar un script como una secuencia única de instrucciones, podemos dividirlo en unidades con propósito propio, entradas conocidas, salida observable y un criterio claro para decidir si la ejecución fue exitosa.

En el ejemplo de esta lección, esa idea se representó con una interfaz Task , implementaciones concretas como CountFilesTask y SummarizeProjectTask , y un despachador que registra las tareas por nombre. Este diseño mantiene separado el flujo principal del script de la lógica específica de cada tarea.

Esta separación es pequeña, pero prepara una idea central para el siguiente paso conceptual: cuando las tareas empiezan a crecer, relacionarse entre sí y repetirse dentro de un flujo de desarrollo, conviene usar un sistema de construcción para coordinarlas.

Puntos clave

  • Una tarea es una unidad de trabajo nombrada, repetible y verificable.
  • El contrato operativo de una tarea incluye intención, entradas, precondiciones, efectos, salida observable y criterio de éxito o falla.
  • Un script puede ofrecer varias tareas pequeñas sin convertirse todavía en un sistema de construcción.
  • Una interfaz como Task permite tratar distintas tareas mediante una abstracción común.
  • El despachador decide qué tarea ejecutar, mientras cada implementación decide cómo realizar su propio trabajo.
  • TaskContext agrupa los datos que una tarea necesita para ejecutarse.

Reflexión de cierre

El cambio importante de esta lección no está en haber escrito más código, sino en haber cambiado la forma de mirar un script. Cuando una operación tiene nombre, propósito, entradas, resultado observable y criterio de éxito, deja de ser solo una instrucción suelta: se convierte en una tarea que podemos entender, revisar, extender y comunicar.

Esa mirada será útil cada vez que una herramienta automatice trabajo por nosotrxs. Antes de preguntar cómo se ejecuta una acción, conviene preguntar qué tarea representa y qué contrato operativo esperamos que cumpla.

¿Con ganas de más?