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 Gemfilebin/: 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.runEn macOS/Linux: chmod +x bin/task_manager. Ejecutarás con: ./bin/task_manager.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
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 end2) 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 end3) 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 end4) 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 end5) 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 end6) 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 end7) 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 end2) 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 end3) 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 end4) 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 end5) 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 endIteració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 end2) 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 end3) 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 end4) 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 endChecklist de “app lista para ampliarse”
- Separación clara: UI (entrada/salida), dominio (reglas), persistencia (archivos).
- Contrato estable: repositorio con
allysave_all. - Errores controlados: resultados de dominio con
Resulty errores de infraestructura capturados enApp. - 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_hyTask.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, crearsearch(query)que filtre portitle. - En UI, añadir opción de menú y mostrar resultados.
3) Añadir “confirmación” antes de eliminar
- Solo en UI: pedir
s/nantes de llamar adelete. - El servicio no cambia: la regla es de interacción, no de negocio.