Construcción de aplicaciones de consola en Ruby con diseño modular

Capítulo 9

Tiempo estimado de lectura: 14 minutos

+ Ejercicio

Objetivo y enfoque del capítulo

Vas a construir una aplicación de consola completa y ampliable con un diseño modular. La idea es separar responsabilidades para que el proyecto crezca sin volverse frágil: una capa de interfaz de consola (UI), una capa de dominio (lógica del negocio) y una capa de persistencia (guardar/cargar datos). El ejemplo será un gestor de tareas con operaciones CRUD (crear, listar, actualizar, eliminar), persistencia en archivo (JSON o CSV) y mejoras iterativas: versión 1 funcional, versión 2 con validaciones y versión 3 con refactor final.

Estructura de archivos propuesta

Una estructura mínima y clara para una app de consola en Ruby:

task_manager/  bin/    task_manager  lib/    task_manager/      app.rb      ui/        console.rb      domain/        task.rb        task_service.rb      persistence/        repository.rb        json_repository.rb        csv_repository.rb      support/        result.rb        errors.rb  data/    tasks.json  Gemfile
  • bin/: punto de entrada ejecutable.
  • lib/task_manager/app.rb: orquestación del flujo (controlador de alto nivel).
  • ui/console.rb: interacción con el usuario (menús, prompts, renderizado).
  • domain/: entidades y servicios del negocio.
  • persistence/: repositorios para guardar y cargar.
  • data/: archivos de datos (JSON/CSV).
  • support/: utilidades (por ejemplo, un objeto Result para devolver éxito/error sin acoplar a la UI).

Gemfile (opcional)

Para este proyecto puedes usar solo la librería estándar. Si quieres, añade json (ya viene en Ruby) y nada más:

source "https://rubygems.org" gem "json"

Punto de entrada: ejecutable en bin/

Crea bin/task_manager y dale permisos de ejecución. Este archivo solo arranca la app (no contiene lógica):

#!/usr/bin/env ruby # bin/task_manager $stdout.sync = true require_relative "../lib/task_manager/app" TaskManager::App.new.run

En macOS/Linux: chmod +x bin/task_manager. Ejecutarás con: ./bin/task_manager.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

Descargar la aplicación

Iteración 1: versión funcional (CRUD + persistencia JSON)

1) Entidad de dominio: Task

Una tarea tiene un identificador, un título y un estado. Mantén la entidad simple y serializable.

# lib/task_manager/domain/task.rb module TaskManager   module Domain     class Task       attr_reader :id       attr_accessor :title, :done        def initialize(id:, title:, done: false)         @id = id         @title = title         @done = done       end        def to_h         { "id" => id, "title" => title, "done" => done }       end        def self.from_h(hash)         new(           id: hash.fetch("id"),           title: hash.fetch("title"),           done: hash.fetch("done", false)         )       end     end   end end

2) Contrato de persistencia: Repository

Define una interfaz (clase base) para que la app no dependa del formato (JSON/CSV). Esto facilita cambiar la persistencia sin tocar la UI ni el servicio.

# lib/task_manager/persistence/repository.rb module TaskManager   module Persistence     class Repository       def all         raise NotImplementedError       end        def save_all(tasks)         raise NotImplementedError       end     end   end end

3) Implementación JSON: JsonRepository

Este repositorio carga y guarda un arreglo de tareas en un archivo JSON.

# lib/task_manager/persistence/json_repository.rb require "json" require_relative "repository" require_relative "../domain/task"  module TaskManager   module Persistence     class JsonRepository < Repository       def initialize(path)         @path = path       end        def all         return [] unless File.exist?(@path)         raw = File.read(@path)         return [] if raw.strip.empty?         data = JSON.parse(raw)         data.map { |h| Domain::Task.from_h(h) }       rescue JSON::ParserError         # Archivo corrupto: en iteración 2 lo mejoraremos         []       end        def save_all(tasks)         payload = tasks.map(&:to_h)         File.write(@path, JSON.pretty_generate(payload))       end     end   end end

4) Servicio de dominio: TaskService

El servicio implementa el CRUD y usa el repositorio. La UI no debe manipular archivos ni reglas internas.

# lib/task_manager/domain/task_service.rb require_relative "task"  module TaskManager   module Domain     class TaskService       def initialize(repository)         @repository = repository       end        def list         @repository.all       end        def create(title)         tasks = @repository.all         next_id = (tasks.map(&:id).max || 0) + 1         task = Task.new(id: next_id, title: title, done: false)         tasks << task         @repository.save_all(tasks)         task       end        def toggle_done(id)         tasks = @repository.all         task = tasks.find { |t| t.id == id }         return nil unless task         task.done = !task.done         @repository.save_all(tasks)         task       end        def update_title(id, new_title)         tasks = @repository.all         task = tasks.find { |t| t.id == id }         return nil unless task         task.title = new_title         @repository.save_all(tasks)         task       end        def delete(id)         tasks = @repository.all         before = tasks.size         tasks.reject! { |t| t.id == id }         @repository.save_all(tasks)         before != tasks.size       end     end   end end

5) UI de consola: Console

La UI solo se encarga de mostrar opciones, leer entradas y presentar resultados. No conoce JSON, ni rutas, ni reglas internas.

# lib/task_manager/ui/console.rb module TaskManager   module UI     class Console       def clear         system("clear") || system("cls")       end        def puts_header(text)         puts "=" * 50         puts text         puts "=" * 50       end        def menu_choice         puts "1) Listar tareas"         puts "2) Crear tarea"         puts "3) Marcar/Desmarcar como hecha"         puts "4) Renombrar tarea"         puts "5) Eliminar tarea"         puts "0) Salir"         print "Elige una opción: "         gets&.strip       end        def ask(prompt)         print prompt         gets&.strip       end        def show_tasks(tasks)         if tasks.empty?           puts "(sin tareas)"           return         end         tasks.each do |t|           mark = t.done ? "[x]" : "[ ]"           puts "#{t.id}. #{mark} #{t.title}"         end       end        def pause         puts "\nPulsa ENTER para continuar..."         gets       end     end   end end

6) Orquestación: App

App conecta UI + servicio + repositorio. Aquí vive el bucle principal del programa.

# lib/task_manager/app.rb require_relative "ui/console" require_relative "domain/task_service" require_relative "persistence/json_repository"  module TaskManager   class App     def initialize(data_path: File.expand_path("../../../data/tasks.json", __dir__))       @ui = UI::Console.new       repo = Persistence::JsonRepository.new(data_path)       @service = Domain::TaskService.new(repo)     end      def run       loop do         @ui.clear         @ui.puts_header("Gestor de tareas")         choice = @ui.menu_choice          case choice         when "1"           list_flow         when "2"           create_flow         when "3"           toggle_flow         when "4"           rename_flow         when "5"           delete_flow         when "0"           break         else           puts "Opción no válida"           @ui.pause         end       end     end      private      def list_flow       @ui.clear       @ui.puts_header("Listado")       @ui.show_tasks(@service.list)       @ui.pause     end      def create_flow       @ui.clear       @ui.puts_header("Crear")       title = @ui.ask("Título: ")       task = @service.create(title.to_s)       puts "Creada: ##{task.id}"       @ui.pause     end      def toggle_flow       @ui.clear       @ui.puts_header("Marcar/Desmarcar")       id = @ui.ask("ID: ").to_i       task = @service.toggle_done(id)       puts(task ? "Actualizada: ##{task.id}" : "No existe ese ID")       @ui.pause     end      def rename_flow       @ui.clear       @ui.puts_header("Renombrar")       id = @ui.ask("ID: ").to_i       title = @ui.ask("Nuevo título: ")       task = @service.update_title(id, title.to_s)       puts(task ? "Renombrada: ##{task.id}" : "No existe ese ID")       @ui.pause     end      def delete_flow       @ui.clear       @ui.puts_header("Eliminar")       id = @ui.ask("ID: ").to_i       ok = @service.delete(id)       puts(ok ? "Eliminada" : "No existe ese ID")       @ui.pause     end   end end

7) Archivo de datos

Crea data/tasks.json con un arreglo vacío:

[]

8) Cargar archivos con require_relative

Observa que cada archivo requiere solo lo que necesita. Esto reduce acoplamiento y evita cargas circulares. Si el proyecto crece, puedes centralizar requires en un archivo lib/task_manager.rb, pero en esta fase es suficiente.

Iteración 2: mejoras de validación y errores sin acoplar la UI

La versión 1 funciona, pero tiene problemas típicos: permite títulos vacíos, no diferencia errores de negocio vs. “no encontrado”, y si el JSON está corrupto se pierde información silenciosamente. Vamos a mejorar sin mezclar responsabilidades.

1) Objeto Result para comunicar éxito/error

En lugar de devolver nil o true/false, devuelve un resultado estructurado. La UI decide cómo mostrarlo.

# lib/task_manager/support/result.rb module TaskManager   module Support     class Result       attr_reader :value, :error        def initialize(ok:, value: nil, error: nil)         @ok = ok         @value = value         @error = error       end        def ok? = @ok        def self.ok(value = nil)         new(ok: true, value: value)       end        def self.fail(error)         new(ok: false, error: error)       end     end   end end

2) Errores de dominio simples

Define errores como objetos o símbolos. Aquí usaremos símbolos y mensajes en un hash para mantenerlo ligero.

# lib/task_manager/support/errors.rb module TaskManager   module Support     ERRORS = {       empty_title: "El título no puede estar vacío.",       not_found: "No se encontró el elemento.",       invalid_id: "El ID debe ser un número válido.",       corrupted_data: "El archivo de datos está corrupto."     }   end end

3) Validaciones en TaskService

El servicio valida entradas y devuelve Result. La UI solo imprime el mensaje.

# lib/task_manager/domain/task_service.rb require_relative "task" require_relative "../support/result" require_relative "../support/errors"  module TaskManager   module Domain     class TaskService       def initialize(repository)         @repository = repository       end        def list         Support::Result.ok(@repository.all)       end        def create(title)         title = title.to_s.strip         return Support::Result.fail(:empty_title) if title.empty?          tasks = @repository.all         next_id = (tasks.map(&:id).max || 0) + 1         task = Task.new(id: next_id, title: title, done: false)         tasks << task         @repository.save_all(tasks)         Support::Result.ok(task)       end        def toggle_done(id)         id = normalize_id(id)         return Support::Result.fail(:invalid_id) unless id          tasks = @repository.all         task = tasks.find { |t| t.id == id }         return Support::Result.fail(:not_found) unless task          task.done = !task.done         @repository.save_all(tasks)         Support::Result.ok(task)       end        def update_title(id, new_title)         id = normalize_id(id)         return Support::Result.fail(:invalid_id) unless id          new_title = new_title.to_s.strip         return Support::Result.fail(:empty_title) if new_title.empty?          tasks = @repository.all         task = tasks.find { |t| t.id == id }         return Support::Result.fail(:not_found) unless task          task.title = new_title         @repository.save_all(tasks)         Support::Result.ok(task)       end        def delete(id)         id = normalize_id(id)         return Support::Result.fail(:invalid_id) unless id          tasks = @repository.all         before = tasks.size         tasks.reject! { |t| t.id == id }         return Support::Result.fail(:not_found) if tasks.size == before          @repository.save_all(tasks)         Support::Result.ok(true)       end        private      def normalize_id(id)         i = Integer(id) rescue nil         return nil if i.nil? || i <= 0         i       end     end   end end

4) Manejo de datos corruptos en el repositorio

En vez de “tragar” el error, puedes elevarlo como un error controlado para que la app lo muestre y no sobrescriba datos. Aquí lo convertimos en una excepción propia del repositorio.

# lib/task_manager/persistence/json_repository.rb require "json" require_relative "repository" require_relative "../domain/task"  module TaskManager   module Persistence     class DataCorruptedError < StandardError; end      class JsonRepository < Repository       def initialize(path)         @path = path       end        def all         return [] unless File.exist?(@path)         raw = File.read(@path)         return [] if raw.strip.empty?         data = JSON.parse(raw)         data.map { |h| Domain::Task.from_h(h) }       rescue JSON::ParserError => e         raise DataCorruptedError, e.message       end        def save_all(tasks)         payload = tasks.map(&:to_h)         File.write(@path, JSON.pretty_generate(payload))       end     end   end end

5) App: capturar el error de persistencia y mostrarlo

La app (orquestador) es un buen lugar para capturar fallos de infraestructura y mostrarlos en la UI.

# lib/task_manager/app.rb require_relative "ui/console" require_relative "domain/task_service" require_relative "persistence/json_repository" require_relative "support/errors"  module TaskManager   class App     def initialize(data_path: File.expand_path("../../../data/tasks.json", __dir__))       @ui = UI::Console.new       repo = Persistence::JsonRepository.new(data_path)       @service = Domain::TaskService.new(repo)     end      def run       loop do         @ui.clear         @ui.puts_header("Gestor de tareas")         choice = @ui.menu_choice          begin           case choice           when "1" then list_flow           when "2" then create_flow           when "3" then toggle_flow           when "4" then rename_flow           when "5" then delete_flow           when "0" then break           else             puts "Opción no válida"             @ui.pause           end         rescue Persistence::DataCorruptedError           puts Support::ERRORS[:corrupted_data]           puts "Revisa data/tasks.json antes de continuar."           @ui.pause         end       end     end      private      def show_result(result)       if result.ok?         yield(result.value) if block_given?       else         puts Support::ERRORS.fetch(result.error, "Error desconocido")       end     end      def list_flow       @ui.clear       @ui.puts_header("Listado")       show_result(@service.list) { |tasks| @ui.show_tasks(tasks) }       @ui.pause     end      def create_flow       @ui.clear       @ui.puts_header("Crear")       title = @ui.ask("Título: ")       show_result(@service.create(title)) { |task| puts "Creada: ##{task.id}" }       @ui.pause     end      def toggle_flow       @ui.clear       @ui.puts_header("Marcar/Desmarcar")       id = @ui.ask("ID: ")       show_result(@service.toggle_done(id)) { |task| puts "Actualizada: ##{task.id}" }       @ui.pause     end      def rename_flow       @ui.clear       @ui.puts_header("Renombrar")       id = @ui.ask("ID: ")       title = @ui.ask("Nuevo título: ")       show_result(@service.update_title(id, title)) { |task| puts "Renombrada: ##{task.id}" }       @ui.pause     end      def delete_flow       @ui.clear       @ui.puts_header("Eliminar")       id = @ui.ask("ID: ")       show_result(@service.delete(id)) { puts "Eliminada" }       @ui.pause     end   end end

Iteración 3: refactor final para ampliar (cambiar JSON por CSV y añadir configuración)

Ahora que el flujo está estable, refactorizamos para que sea fácil añadir nuevas pantallas, nuevos repositorios o nuevos comandos.

1) Configuración básica centralizada

Crea un pequeño objeto de configuración para rutas y formato. Así evitas “strings mágicos” repartidos.

# lib/task_manager/config.rb module TaskManager   class Config     attr_reader :data_path, :storage      def initialize(data_path:, storage:)       @data_path = data_path       @storage = storage # :json o :csv     end      def self.default       root = File.expand_path("../..", __dir__)       new(         data_path: File.join(root, "data", "tasks.json"),         storage: :json       )     end   end end

2) Fábrica de repositorios

Selecciona la implementación según configuración. Esto evita condicionales dispersos.

# lib/task_manager/persistence/repository_factory.rb require_relative "json_repository" require_relative "csv_repository"  module TaskManager   module Persistence     class RepositoryFactory       def self.build(storage:, path:)         case storage         when :json then JsonRepository.new(path)         when :csv  then CsvRepository.new(path)         else           raise ArgumentError, "storage no soportado: #{storage}"         end       end     end   end end

3) Implementación CSV (alternativa)

CSV es útil si quieres inspeccionar/editar manualmente. Mantén el mismo contrato all/save_all.

# lib/task_manager/persistence/csv_repository.rb require "csv" require_relative "repository" require_relative "../domain/task"  module TaskManager   module Persistence     class CsvRepository < Repository       def initialize(path)         @path = path       end        def all         return [] unless File.exist?(@path)         rows = CSV.read(@path, headers: true)         rows.map do |r|           Domain::Task.new(             id: Integer(r["id"]),             title: r["title"].to_s,             done: r["done"].to_s == "true"           )         end       rescue ArgumentError, CSV::MalformedCSVError => e         raise DataCorruptedError, e.message       end        def save_all(tasks)         CSV.open(@path, "w") do |csv|           csv << ["id", "title", "done"]           tasks.each { |t| csv << [t.id, t.title, t.done] }         end       end     end   end end

4) App usando Config + Factory

La app ya no sabe qué repositorio usa; solo pide uno.

# lib/task_manager/app.rb require_relative "config" require_relative "ui/console" require_relative "domain/task_service" require_relative "persistence/repository_factory" require_relative "support/errors"  module TaskManager   class App     def initialize(config: Config.default)       @ui = UI::Console.new       repo = Persistence::RepositoryFactory.build(storage: config.storage, path: config.data_path)       @service = Domain::TaskService.new(repo)     end      # run y flujos se mantienen igual (iteración 2)   end end

Checklist de “app lista para ampliarse”

  • Separación clara: UI (entrada/salida), dominio (reglas), persistencia (archivos).
  • Contrato estable: repositorio con all y save_all.
  • Errores controlados: resultados de dominio con Result y errores de infraestructura capturados en App.
  • Persistencia intercambiable: JSON/CSV sin tocar el servicio ni la UI.
  • Extensión natural: puedes añadir campos (prioridad, fecha), nuevas opciones de menú (buscar, filtrar), o cambiar a otro dominio (gastos, inventario) manteniendo la arquitectura.

Ejercicios prácticos de ampliación (sin cambiar la arquitectura)

1) Añadir “prioridad” a Task

  • Actualizar Task#to_h y Task.from_h.
  • Modificar UI para pedir prioridad al crear.
  • Actualizar repositorios JSON/CSV para persistir el nuevo campo.

2) Añadir comando “buscar por texto”

  • En TaskService, crear search(query) que filtre por title.
  • En UI, añadir opción de menú y mostrar resultados.

3) Añadir “confirmación” antes de eliminar

  • Solo en UI: pedir s/n antes de llamar a delete.
  • El servicio no cambia: la regla es de interacción, no de negocio.

Ahora responde el ejercicio sobre el contenido:

¿Qué cambio de diseño permite que la capa de UI muestre errores y resultados sin depender de valores como nil o true/false del dominio?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Un Result encapsula éxito o fallo (con ok?, value y error). Así el dominio comunica el estado sin acoplarse a la UI, y la UI decide cómo mostrar los mensajes.

Siguiente capítulo

Automatización y scripts eficientes en Ruby para tareas reales

Arrow Right Icon
Portada de libro electrónico gratuitaRuby desde Cero para principiantes: Programación Moderna, Clara y Eficiente
90%

Ruby desde Cero para principiantes: Programación Moderna, Clara y Eficiente

Nuevo curso

10 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.