Saltar al contenido principal

Proyecto: aplicación con colecciones y persistencia local

En este proyecto construirás un gestor de tareas que conserva sus datos en un archivo local. El objetivo no es acumular APIs, sino separar responsabilidades: el modelo representa cada tarea, el servicio aplica las reglas y el repositorio se ocupa del archivo.

Qué vamos a construir​

La aplicación podrá:

  • registrar tareas con identificador, título, fecha límite y etiquetas;
  • impedir identificadores duplicados y datos inválidos;
  • marcar tareas como completadas;
  • listar las tareas pendientes ordenadas por fecha;
  • guardar una instantánea en UTF-8 y recuperarla en la siguiente ejecución.

No necesitamos una base de datos ni dependencias externas. Usaremos Java 17, colecciones, java.time, excepciones, Path y Files.

Diseño antes de programar​

Main
├── coordina el caso de uso
├── GestorTareas: reglas y colección en memoria
└── RepositorioTareas: lectura y escritura del archivo
└── Tarea: estado del dominio

El gestor usa un Map<String, Tarea> porque cada identificador debe ser único y queremos localizar una tarea por su clave. Cada tarea usa un Set<String> porque repetir una etiqueta no aporta información.

El archivo será una instantánea: cada guardado reemplaza su contenido con el estado actual. Este criterio es sencillo y suficiente para un fichero pequeño; no pretende sustituir una base de datos.

Estructura del proyecto​

gestor-tareas/
├── datos/
│ └── tareas.txt # se crea al ejecutar
└── src/
├── app/Main.java
├── excepciones/DatoInvalidoException.java
├── modelo/Estado.java
├── modelo/Tarea.java
├── persistencia/RepositorioTareas.java
└── servicio/GestorTareas.java

Los paquetes evitan mezclar clases con responsabilidades distintas. Crea cada archivo en la carpeta indicada.

1. Estado de una tarea​

src/modelo/Estado.java:

package modelo;

public enum Estado {
PENDIENTE,
COMPLETADA
}

Un enum limita el estado a los dos valores válidos. Un texto libre permitiría errores como "completadaa".

2. Excepción del dominio​

src/excepciones/DatoInvalidoException.java:

package excepciones;

public class DatoInvalidoException extends Exception {
public DatoInvalidoException(String mensaje) {
super(mensaje);
}
}

Es una excepción checked: quien registra o reconstruye una tarea debe decidir cómo comunicar un dato inválido. No la usamos para condiciones normales como consultar una colección vacía.

3. Modelo Tarea​

src/modelo/Tarea.java:

package modelo;

import excepciones.DatoInvalidoException;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.Set;

public class Tarea {
private final String id;
private final String titulo;
private final LocalDate fechaLimite;
private final Set<String> etiquetas;
private Estado estado;

public Tarea(String id, String titulo, LocalDate fechaLimite,
Set<String> etiquetas, Estado estado)
throws DatoInvalidoException {
if (id == null || id.isBlank() || id.contains("|")) {
throw new DatoInvalidoException("Identificador vacío o con el carácter |");
}
if (titulo == null || titulo.isBlank() || titulo.contains("|")) {
throw new DatoInvalidoException("Título vacío o con el carácter |");
}
if (fechaLimite == null || estado == null || etiquetas == null) {
throw new DatoInvalidoException("Faltan datos obligatorios");
}
for (String etiqueta : etiquetas) {
if (etiqueta.isBlank() || etiqueta.contains(",") || etiqueta.contains("|")) {
throw new DatoInvalidoException("Etiqueta vacía o con un separador reservado");
}
}
this.id = id;
this.titulo = titulo;
this.fechaLimite = fechaLimite;
this.etiquetas = new HashSet<>(etiquetas);
this.estado = estado;
}

public String getId() { return id; }
public String getTitulo() { return titulo; }
public LocalDate getFechaLimite() { return fechaLimite; }
public Set<String> getEtiquetas() { return Set.copyOf(etiquetas); }
public Estado getEstado() { return estado; }

public void completar() {
estado = Estado.COMPLETADA;
}

@Override
public String toString() {
return id + " · " + titulo + " · " + fechaLimite + " · " + estado;
}
}

La copia del conjunto al entrar y al salir protege la colección interna. Además, validamos los separadores reservados por el formato del archivo; así un título no puede romper una línea al guardarse.

4. Servicio y reglas​

src/servicio/GestorTareas.java:

package servicio;

import excepciones.DatoInvalidoException;
import modelo.Estado;
import modelo.Tarea;

import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class GestorTareas {
private final Map<String, Tarea> tareas = new HashMap<>();

public void registrar(Tarea tarea) throws DatoInvalidoException {
if (tareas.containsKey(tarea.getId())) {
throw new DatoInvalidoException("Ya existe la tarea " + tarea.getId());
}
tareas.put(tarea.getId(), tarea);
}

public boolean completar(String id) {
Tarea tarea = tareas.get(id);
if (tarea == null) return false;
tarea.completar();
return true;
}

public List<Tarea> pendientesPorFecha() {
return tareas.values().stream()
.filter(tarea -> tarea.getEstado() == Estado.PENDIENTE)
.sorted((a, b) -> a.getFechaLimite().compareTo(b.getFechaLimite()))
.toList();
}

public long contarPendientes() {
return tareas.values().stream()
.filter(tarea -> tarea.getEstado() == Estado.PENDIENTE)
.count();
}

public boolean estaVacio() { return tareas.isEmpty(); }

public Collection<Tarea> todas() {
return List.copyOf(tareas.values());
}
}

completar devuelve false cuando la clave no existe porque es un resultado esperado que quien llama puede comprobar. Registrar un duplicado incumple una regla del caso de uso y produce nuestra excepción con contexto.

5. Formato persistente​

Cada línea tendrá cinco campos separados por |:

id|titulo|fecha-limite|estado|etiqueta1,etiqueta2

Por ejemplo:

T-1|Repasar colecciones|2026-10-10|PENDIENTE|java,estudio

Es un formato deliberadamente pequeño y legible. Sus límites también son explícitos: los textos no admiten | y las etiquetas no admiten comas. En una aplicación real podríamos elegir un formato más robusto, pero exigiría herramientas que quedan fuera de este curso.

6. Repositorio de archivos​

src/persistencia/RepositorioTareas.java:

package persistencia;

import excepciones.DatoInvalidoException;
import modelo.Estado;
import modelo.Tarea;
import servicio.GestorTareas;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

public class RepositorioTareas {
private final Path archivo;

public RepositorioTareas(Path archivo) {
this.archivo = archivo;
}

public GestorTareas cargar() throws IOException {
GestorTareas gestor = new GestorTareas();
if (!Files.exists(archivo)) return gestor;

List<String> lineas = Files.readAllLines(archivo, StandardCharsets.UTF_8);
for (int i = 0; i < lineas.size(); i++) {
String linea = lineas.get(i);
try {
gestor.registrar(convertir(linea));
} catch (DatoInvalidoException | DateTimeParseException |
IllegalArgumentException e) {
System.out.println("Línea " + (i + 1) + " ignorada: " + e.getMessage());
}
}
return gestor;
}

public void guardar(GestorTareas gestor) throws IOException {
Path directorio = archivo.getParent();
if (directorio != null) Files.createDirectories(directorio);

List<String> lineas = gestor.todas().stream()
.sorted((a, b) -> a.getId().compareTo(b.getId()))
.map(this::convertir)
.toList();
Files.write(archivo, lineas, StandardCharsets.UTF_8);
}

private Tarea convertir(String linea) throws DatoInvalidoException {
String[] campos = linea.split("\\|", -1);
if (campos.length != 5) {
throw new DatoInvalidoException("se esperaban cinco campos");
}
Set<String> etiquetas = new HashSet<>();
if (!campos[4].isBlank()) {
etiquetas.addAll(Arrays.asList(campos[4].split(",")));
}
return new Tarea(campos[0], campos[1], LocalDate.parse(campos[2]),
etiquetas, Estado.valueOf(campos[3]));
}

private String convertir(Tarea tarea) {
String etiquetas = String.join(",",
tarea.getEtiquetas().stream().sorted().toList());
return String.join("|", tarea.getId(), tarea.getTitulo(),
tarea.getFechaLimite().toString(), tarea.getEstado().name(), etiquetas);
}
}

La ausencia inicial del archivo no es un error: significa que todavía no hay tareas. En cambio, un fallo de lectura o escritura se propaga como IOException porque el repositorio no sabe cómo debe reaccionar la interfaz de usuario.

Una línea defectuosa se comunica y se omite sin perder las demás. No usamos un catch (Exception) genérico: capturamos solo los problemas que una línea externa puede provocar.

En linea.split("\\|", -1), split interpreta un patrón y por eso escapamos el carácter | para tratarlo como separador literal. El valor -1 conserva los campos vacíos del final: una tarea sin etiquetas sigue teniendo cinco campos y puede reconstruirse correctamente.

7. Coordinar la aplicación​

src/app/Main.java:

package app;

import excepciones.DatoInvalidoException;
import modelo.Estado;
import modelo.Tarea;
import persistencia.RepositorioTareas;
import servicio.GestorTareas;

import java.io.IOException;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.Set;

public class Main {
public static void main(String[] args) {
RepositorioTareas repositorio =
new RepositorioTareas(Path.of("datos", "tareas.txt"));

try {
GestorTareas gestor = repositorio.cargar();
if (gestor.estaVacio()) {
gestor.registrar(new Tarea("T-1", "Repasar colecciones",
LocalDate.of(2026, 10, 10), Set.of("java", "estudio"),
Estado.PENDIENTE));
gestor.registrar(new Tarea("T-2", "Practicar ficheros",
LocalDate.of(2026, 10, 12), Set.of("java"),
Estado.PENDIENTE));
}

gestor.completar("T-1");
System.out.println("Pendientes: " + gestor.contarPendientes());
gestor.pendientesPorFecha().forEach(System.out::println);
repositorio.guardar(gestor);
System.out.println("Datos guardados");
} catch (DatoInvalidoException e) {
System.out.println("Datos rechazados: " + e.getMessage());
} catch (IOException e) {
System.out.println("No se pudo acceder al archivo: " + e.getMessage());
}
}
}

Main coordina, pero no conoce cómo se separa una línea ni cómo se almacena el mapa. En una segunda ejecución carga las tareas existentes y vuelve a guardar una instantánea equivalente, sin duplicarlas.

Compilar y ejecutar​

Desde gestor-tareas, crea una carpeta para los .class y compila todos los fuentes:

mkdir -p out
javac --release 17 -d out src/excepciones/*.java src/modelo/*.java src/servicio/*.java src/persistencia/*.java src/app/*.java
java -cp out app.Main

Salida esperada la primera vez:

Pendientes: 1
T-2 · Practicar ficheros · 2026-10-12 · PENDIENTE
Datos guardados

Ejecuta otra vez. El resultado debe mantenerse y datos/tareas.txt debe contener dos líneas, no cuatro.

Pruebas manuales importantes​

  1. Inicio limpio: elimina solo datos/tareas.txt y ejecuta; el programa debe crearlo.
  2. Persistencia: ejecuta dos veces; no deben aparecer duplicados.
  3. Línea dañada: añade línea incorrecta; debe avisar de la línea ignorada y cargar las válidas.
  4. Fecha inválida: cambia una fecha por 2026-99-99; debe ignorar esa línea.
  5. Identificador repetido: duplica una línea; la segunda debe rechazarse.
  6. Ruta imposible: apunta el repositorio a una ubicación no escribible; Main debe comunicar el fallo de acceso.

Estas pruebas cubren caminos normales, límites y errores observables. No sustituyen tests automatizados, que estudiaremos más adelante.

Errores habituales y decisiones​

  • Guardar objetos directamente. Aquí persistimos un formato de texto explícito, no serialización de objetos.
  • Mezclar todo en Main. Dificulta cambiar el formato o reutilizar las reglas.
  • Silenciar una línea inválida. Informamos su posición para poder corregir el archivo.
  • Usar una lista para buscar por ID. El mapa expresa mejor una clave única.
  • Devolver las colecciones internas. Las copias evitan cambios que salten las reglas del gestor y de la tarea.
  • Usar streams para cada paso. Un bucle resulta más claro al procesar líneas que pueden fallar individualmente; el stream aporta valor al ordenar, filtrar o transformar.

Mini reto​

Añade una consulta de tareas cuya fecha límite sea anterior a una fecha recibida como parámetro. Devuelve una lista ordenada y no modifiques la colección interna. Después incorpora una etiqueta nueva a los datos iniciales y comprueba que se guarda ordenada en el archivo.

Una solución coherente añadiría el método al GestorTareas, porque es una consulta del dominio. El repositorio debe seguir limitándose a cargar y guardar; colocar allí el filtro mezclaría persistencia con reglas de negocio.

Has recorrido el ciclo completo: modelar datos, elegir colecciones, validar, tratar errores, trabajar con fechas, transformar información y conservarla localmente. Esa separación de responsabilidades será la base para aplicaciones Java más grandes.