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
, 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.
Task Task
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:
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 que ejecuta su trabajo.
run() run()
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 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.
Task Task
-
permite identificar la tarea desde la línea de comandos.namename -
permite mostrar ayuda o listar las tareas disponibles.descriptiondescription -
agrupa la información que la tarea necesita para ejecutarse.TaskContextTaskContext -
hace explícito si la ejecución terminó bien o con error.TaskResultTaskResult
Esta estructura evita que el script dependa de casos especiales para cada comando. Mientras una
operación implemente , el despachador puede registrarla, encontrarla
por nombre y ejecutarla usando el mismo flujo.
Task Task
Por ejemplo, una tarea concreta puede implementar esa interfaz sin cambiar el resto del script:
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.
// 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.
-
transforma la lista de tareas en un diccionario, usando el nombre de cada tarea como clave. Por esoassociateBy {}associateBy {}puede encontrar directamente la instancia detasks["summarize"]tasks["summarize"].SummarizeProjectTaskSummarizeProjectTask -
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.firstOrNull()firstOrNull() -
intenta leer el segundo argumento (es decir, el elemento con índicegetOrElse(1) { "." }getOrElse(1) { "." }). Si no fue entregado, usa11como ruta por defecto, es decir, la carpeta actual.".""." -
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.exitProcess()exitProcess()
¿Y new new ?
new new
Al leer estos ejemplos, puede llamarte la atención que Kotlin no use para crear instancias (o puede que no te llame la atención, no te juzgo):
new new 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() CountFilesTask() se lee como construcción de un objeto, mientras que
CountFilesTask() CountFilesTask() se lee como invocación de una función.
countFiles() countFiles()
Esta convención también explica por qué la ausencia de no
debería volver ambiguo el código: la diferencia principal no está en una palabra clave, sino en
nombrar consistentemente tipos y operaciones.
new new
Discusión extra: ¿debería existir una palabra clave como new new ?
new 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
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.
new new
Quienes defienden una palabra clave como 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.
new new
Para esta lección, la conclusión práctica es más simple: si el lenguaje no exige
, 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.
new new
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 Task
- El nombre escrito en la línea de comandos identifica la tarea solicitada.
- La tabla
permite buscar una implementación sin escribir casos especiales para cada comando.taskstasks - El
agrupa los datos compartidos que la tarea necesita para ejecutarse.TaskContextTaskContext - El
concentra el mensaje final y el código de salida.TaskResultTaskResult
Un detalle importante es que los nombres de las tareas deberían ser únicos. Si dos tareas usan el mismo
, la tabla generada con name name 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.
associateBy() associateBy()
Lo importante es que agregar una nueva tarea no exige reescribir el flujo principal. Basta con crear otra
clase que implemente 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.
Task Task
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
que implemente la interfazListTasksTaskListTasksTask.TaskTask - Define su
comonamenamey su"list""list"como una descripción breve de la tarea.descriptiondescription - Extiende
para que también incluya las tareas disponibles.TaskContextTaskContext - La tarea debe listar cada tarea disponible usando el formato
."- ${task.name}: ${task.description}""- ${task.name}: ${task.description}" - La ejecución debe retornar un
.SuccessfulTaskResultSuccessfulTaskResult - Registra la nueva tarea en el despachador para que pueda ejecutarse con:
.kotlin library-tasks.main.kts listkotlin library-tasks.main.kts list
Notas
La tarea necesita conocer las tareas registradas, pero no debería
construir esa colección ni depender de una referencia incompleta al despachador.
list list
Una forma más segura es pasar la información disponible mediante . Así, el despachador construye el contexto cuando ya conoce el mapa completo
de tareas.
TaskContext TaskContext
Uso esperado
kotlin library-tasks.main.kts listLa salida debería tener una forma similar a esta:
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
al constructor de{ tasks.values }{ tasks.values }. Aunque puede funcionar, deja una dependencia fácil de usar mal.ListTasksTaskListTasksTask - En lugar de eso, agrega una propiedad a
, por ejemploTaskContextTaskContext.availableTasks: Collection<Task>availableTasks: Collection<Task> - Cuando el despachador construya el contexto, pásale
. En ese punto, el mapa de tareas ya está completo.tasks.valuestasks.values - Dentro de
, usaListTasksTask.runListTasksTask.runpara construir la salida.context.availableTaskscontext.availableTasks - Usa
para crear una línea por cada tarea disponible.joinToString(System.lineSeparator())joinToString(System.lineSeparator())
Solución
Primero, el contexto de ejecución puede incluir tanto la ruta del proyecto como la colección de tareas disponibles:
class TaskContext(
val projectRoot: Path,
val availableTasks: Collection<Task>,
)
Luego, 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.
ListTasksTask ListTasksTask
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, contiene la colección completa.
availableTasks availableTasks
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 sería pasarle una función que
entregue las tareas disponibles. Esa idea parece cómoda porque permite escribir ListTasksTask ListTasksTask y consultar el mapa solo cuando la tarea se ejecuta.
{ tasks.values }{ tasks.values }
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"
)
}
}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
demasiado pronto, el script puede fallar con un tasks() tasks() .
NullPointerException NullPointerException
NullPointerException 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 : el
contexto se construye cuando el mapa ya está completo.
TaskContext TaskContext
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 , implementaciones concretas como Task Task y
CountFilesTask CountFilesTask , 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.
SummarizeProjectTask SummarizeProjectTask
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
permite tratar distintas tareas mediante una abstracción común.TaskTask - El despachador decide qué tarea ejecutar, mientras cada implementación decide cómo realizar su propio trabajo.
-
agrupa los datos que una tarea necesita para ejecutarse.TaskContextTaskContext
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?
Referencias recomendadas
- “ Coding conventions ” en Kotlin docs por JetBrains
Esta guía oficial de Kotlin presenta convenciones de organización, nombres y formato. Es útil después de esta lección porque ayuda a entender por qué nombres como
,CountFilesTaskCountFilesTaskyTaskContextTaskContextno son solo detalles estéticos: comunican qué elementos son clases, funciones, propiedades o valores.run()run()