Mini-projeto integrador (console, sem frameworks): visão geral
Neste mini-projeto, você vai consolidar fundamentos em um aplicativo de console com persistência em arquivo. A ideia é exercitar: modelagem com classes, organização em pacotes, uso de coleções para manter dados em memória, leitura/escrita de arquivo para persistir, validações e tratamento de exceções com mensagens úteis. O foco é construir algo pequeno, mas com requisitos claros e critérios de aceite objetivos.
Proposta do projeto: “Gerenciador de Tarefas”
Você criará um sistema de tarefas (to-do) com operações de cadastro, listagem, busca e atualização de status, persistindo em arquivo local. O usuário interage por menu no console.
Requisitos funcionais (o que o sistema deve fazer)
- Cadastrar tarefa: informar título (obrigatório), descrição (opcional) e prioridade (BAIXA/MEDIA/ALTA).
- Listar tarefas: exibir todas, com ID, título, prioridade e status (PENDENTE/CONCLUIDA).
- Buscar: buscar por ID e buscar por termo no título (case-insensitive).
- Concluir tarefa: marcar uma tarefa como concluída pelo ID.
- Remover tarefa: remover pelo ID.
- Persistência: ao iniciar, carregar tarefas do arquivo; ao alterar dados, salvar no arquivo.
- Validações: título não pode ser vazio; prioridade deve ser válida; IDs devem existir para concluir/remover.
- Mensagens de erro: claras e orientativas (ex.: “ID não encontrado: 10”).
Requisitos não funcionais (qualidade e organização)
- Separação de responsabilidades: UI (menu) não deve conter lógica de persistência nem regras de negócio.
- Organização em pacotes: por camadas simples (ui, service, repository, model, util).
- Sem frameworks: apenas Java padrão.
- Build: empacotado com Maven ou Gradle (escolha um).
Modelo de dados (classes principais)
Entidades e enums
Task: id (long), title (String), description (String), priority (Priority), status (Status), createdAt (LocalDateTime).Priority: BAIXA, MEDIA, ALTA.Status: PENDENTE, CONCLUIDA.
Regras de negócio (service)
TaskService: valida entradas, coordena repositório, gera IDs, aplica regras (ex.: não concluir tarefa inexistente).
Persistência (repository)
TaskRepository: interface com operações (load, save, find, add, remove, update).FileTaskRepository: implementação que lê/escreve em arquivo (CSV simples).
Interface de console (ui)
ConsoleMenu: mostra opções, lê entradas, chama o service e imprime resultados.
Estrutura sugerida de pacotes
src/main/java/com/seuapp/todo/ ├─ app/ (classe Main) ├─ ui/ (ConsoleMenu, ConsoleIO) ├─ model/ (Task, Priority, Status) ├─ service/ (TaskService) ├─ repository/ (TaskRepository, FileTaskRepository) └─ util/ (Validators, Texts, DateFormats)Dica prática: manter um pacote util pequeno e objetivo evita “classes utilitárias” virarem um depósito de código sem dono. Se algo crescer, promova para uma classe de responsabilidade própria.
Formato de persistência (CSV simples)
Para evitar complexidade, use um arquivo CSV com separador ;. Cada linha representa uma tarefa.
Exemplo de linha:
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
1;Estudar Java;Revisar coleções e I/O;ALTA;PENDENTE;2026-01-25T10:30:00Regras do CSV
- Campos na ordem:
id;title;description;priority;status;createdAt. - Se
descriptionestiver vazia, gravar como vazio (nada entre;;). - Para simplificar, proíba
;no título/descrição (validação) ou substitua por vírgula ao salvar.
Passo a passo incremental (construa em pequenas entregas)
Passo 1 — Esqueleto do projeto e ponto de entrada
- Crie a classe
Mainque instanciaConsoleMenue chamastart(). - Implemente um menu mínimo com “Sair”.
package com.seuapp.todo.app; import com.seuapp.todo.ui.ConsoleMenu; public class Main { public static void main(String[] args) { new ConsoleMenu().start(); } }Passo 2 — Modelagem: Task, Priority, Status
- Crie os enums.
- Crie
Taskcom construtor, getters etoString()amigável para listagem. - Evite setters indiscriminados: permita mudar apenas o que faz sentido (ex.: status).
package com.seuapp.todo.model; import java.time.LocalDateTime; public class Task { private final long id; private final String title; private final String description; private final Priority priority; private Status status; private final LocalDateTime createdAt; public Task(long id, String title, String description, Priority priority, Status status, LocalDateTime createdAt) { this.id = id; this.title = title; this.description = description; this.priority = priority; this.status = status; this.createdAt = createdAt; } public long getId() { return id; } public String getTitle() { return title; } public String getDescription() { return description; } public Priority getPriority() { return priority; } public Status getStatus() { return status; } public LocalDateTime getCreatedAt() { return createdAt; } public void markDone() { this.status = Status.CONCLUIDA; } }Passo 3 — Repositório em memória (antes do arquivo)
Comece com uma implementação simples em memória para validar o fluxo do menu sem lidar com I/O ainda. Use uma coleção adequada:
- Map<Long, Task> para acesso rápido por ID.
- Ao listar, você pode ordenar por ID (convertendo para lista e ordenando) ou manter uma lista paralela. Para simplicidade, ordene na hora.
package com.seuapp.todo.repository; import com.seuapp.todo.model.Task; import java.util.*; public class InMemoryTaskRepository implements TaskRepository { private final Map<Long, Task> tasks = new HashMap<>(); @Override public List<Task> findAll() { List<Task> list = new ArrayList<>(tasks.values()); list.sort(Comparator.comparingLong(Task::getId)); return list; } @Override public Optional<Task> findById(long id) { return Optional.ofNullable(tasks.get(id)); } @Override public void save(Task task) { tasks.put(task.getId(), task); } @Override public boolean delete(long id) { return tasks.remove(id) != null; } }Interface sugerida:
package com.seuapp.todo.repository; import com.seuapp.todo.model.Task; import java.util.*; public interface TaskRepository { List<Task> findAll(); Optional<Task> findById(long id); void save(Task task); boolean delete(long id); }Passo 4 — Service com validações e regras
O service centraliza validações e mensagens de erro. Ele também pode gerenciar a geração de IDs (ex.: manter um contador baseado no maior ID existente).
package com.seuapp.todo.service; import com.seuapp.todo.model.*; import com.seuapp.todo.repository.TaskRepository; import java.time.LocalDateTime; import java.util.List; public class TaskService { private final TaskRepository repo; private long nextId = 1; public TaskService(TaskRepository repo) { this.repo = repo; long max = repo.findAll().stream().mapToLong(Task::getId).max().orElse(0); this.nextId = max + 1; } public Task create(String title, String description, Priority priority) { validateTitle(title); if (priority == null) throw new IllegalArgumentException("Prioridade inválida"); long id = nextId++; Task task = new Task(id, title.trim(), safe(description), priority, Status.PENDENTE, LocalDateTime.now()); repo.save(task); return task; } public List<Task> list() { return repo.findAll(); } public Task getById(long id) { return repo.findById(id).orElseThrow(() -> new IllegalArgumentException("ID não encontrado: " + id)); } public void markDone(long id) { Task t = getById(id); t.markDone(); repo.save(t); } public void remove(long id) { boolean ok = repo.delete(id); if (!ok) throw new IllegalArgumentException("ID não encontrado: " + id); } private void validateTitle(String title) { if (title == null || title.trim().isEmpty()) throw new IllegalArgumentException("Título é obrigatório"); if (title.contains(";")) throw new IllegalArgumentException("Título não pode conter ';'"); } private String safe(String s) { if (s == null) return ""; if (s.contains(";")) throw new IllegalArgumentException("Descrição não pode conter ';'"); return s.trim(); } }Passo 5 — UI de console (menu + parsing de entrada)
Mantenha a UI responsável apenas por: mostrar opções, ler strings, converter para tipos e exibir resultados. Se algo falhar, capture a exceção e mostre a mensagem.
package com.seuapp.todo.ui; import com.seuapp.todo.model.Priority; import com.seuapp.todo.repository.InMemoryTaskRepository; import com.seuapp.todo.service.TaskService; import java.util.Scanner; public class ConsoleMenu { private final Scanner sc = new Scanner(System.in); private final TaskService service = new TaskService(new InMemoryTaskRepository()); public void start() { while (true) { printMenu(); String op = sc.nextLine().trim(); try { switch (op) { case "1" -> create(); case "2" -> list(); case "3" -> findById(); case "4" -> markDone(); case "5" -> remove(); case "0" -> { return; } default -> System.out.println("Opção inválida"); } } catch (IllegalArgumentException e) { System.out.println("Erro: " + e.getMessage()); } catch (Exception e) { System.out.println("Erro inesperado: " + e.getClass().getSimpleName()); } } } private void printMenu() { System.out.println("\n1) Cadastrar 2) Listar 3) Buscar por ID 4) Concluir 5) Remover 0) Sair"); System.out.print("Escolha: "); } private void create() { System.out.print("Título: "); String title = sc.nextLine(); System.out.print("Descrição (opcional): "); String desc = sc.nextLine(); System.out.print("Prioridade (BAIXA, MEDIA, ALTA): "); Priority p = Priority.valueOf(sc.nextLine().trim().toUpperCase()); var t = service.create(title, desc, p); System.out.println("Criada: ID " + t.getId()); } private void list() { service.list().forEach(t -> System.out.println(t.getId() + " | " + t.getTitle() + " | " + t.getPriority() + " | " + t.getStatus())); } private void findById() { System.out.print("ID: "); long id = Long.parseLong(sc.nextLine().trim()); var t = service.getById(id); System.out.println(t.getId() + " | " + t.getTitle() + " | " + t.getPriority() + " | " + t.getStatus()); } private void markDone() { System.out.print("ID: "); long id = Long.parseLong(sc.nextLine().trim()); service.markDone(id); System.out.println("Tarefa concluída."); } private void remove() { System.out.print("ID: "); long id = Long.parseLong(sc.nextLine().trim()); service.remove(id); System.out.println("Tarefa removida."); } }Observação importante: o Priority.valueOf lança exceção se a entrada for inválida. Isso é bom aqui, desde que você capture e mostre uma mensagem clara. Se quiser melhorar, crie um parser que aceite “media”/“média” e normalize.
Passo 6 — Trocar repositório em memória por repositório em arquivo
Agora que o fluxo funciona, substitua o repositório por uma implementação que carrega do arquivo ao iniciar e salva a cada alteração. Uma abordagem simples:
FileTaskRepositorymantém umMap<Long, Task>em memória.- No construtor, chama
load()(se arquivo não existir, começa vazio). - Em
save()edelete(), após alterar o mapa, chamapersist()para regravar o arquivo inteiro.
package com.seuapp.todo.repository; import com.seuapp.todo.model.*; import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.time.LocalDateTime; import java.util.*; public class FileTaskRepository implements TaskRepository { private final Path file; private final Map<Long, Task> tasks = new HashMap<>(); public FileTaskRepository(Path file) { this.file = file; load(); } private void load() { if (!Files.exists(file)) return; try (BufferedReader br = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { String line; while ((line = br.readLine()) != null) { if (line.trim().isEmpty()) continue; Task t = parse(line); tasks.put(t.getId(), t); } } catch (IOException e) { throw new UncheckedIOException("Falha ao ler arquivo: " + file, e); } } private Task parse(String line) { String[] parts = line.split(";", -1); if (parts.length != 6) throw new IllegalArgumentException("Linha inválida no arquivo: " + line); long id = Long.parseLong(parts[0]); String title = parts[1]; String desc = parts[2]; Priority pr = Priority.valueOf(parts[3]); Status st = Status.valueOf(parts[4]); LocalDateTime createdAt = LocalDateTime.parse(parts[5]); return new Task(id, title, desc, pr, st, createdAt); } private String format(Task t) { return t.getId() + ";" + t.getTitle() + ";" + t.getDescription() + ";" + t.getPriority() + ";" + t.getStatus() + ";" + t.getCreatedAt(); } private void persist() { try { Files.createDirectories(file.getParent()); } catch (IOException e) { throw new UncheckedIOException("Falha ao criar diretório: " + file.getParent(), e); } List<Task> list = new ArrayList<>(tasks.values()); list.sort(Comparator.comparingLong(Task::getId)); try (BufferedWriter bw = Files.newBufferedWriter(file, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { for (Task t : list) { bw.write(format(t)); bw.newLine(); } } catch (IOException e) { throw new UncheckedIOException("Falha ao salvar arquivo: " + file, e); } } @Override public List<Task> findAll() { List<Task> list = new ArrayList<>(tasks.values()); list.sort(Comparator.comparingLong(Task::getId)); return list; } @Override public Optional<Task> findById(long id) { return Optional.ofNullable(tasks.get(id)); } @Override public void save(Task task) { tasks.put(task.getId(), task); persist(); } @Override public boolean delete(long id) { boolean removed = tasks.remove(id) != null; if (removed) persist(); return removed; } }Ponto de atenção: aqui usamos UncheckedIOException para propagar falhas de I/O sem poluir assinaturas. Na UI, trate como “erro inesperado” e mostre uma mensagem curta. Se preferir, crie uma exceção própria (ex.: PersistenceException).
Passo 7 — Ajustar a UI para usar o repositório em arquivo
Troque a instanciação do repositório na UI, apontando para um caminho previsível (ex.: data/tasks.csv).
import com.seuapp.todo.repository.FileTaskRepository; import java.nio.file.Path; // ... private final TaskService service = new TaskService(new FileTaskRepository(Path.of("data", "tasks.csv")));Empacotamento com Maven (mini-guia prático do projeto)
Estrutura mínima
pom.xml src/main/java/... src/main/resources/ (opcional) data/ (gerado em runtime, não versionar)Configuração essencial no pom.xml
Defina versão do Java e um jeito simples de executar. Se você quiser gerar um JAR executável, configure o plugin para incluir o Main-Class.
<project> <modelVersion>4.0.0</modelVersion> <groupId>com.seuapp</groupId> <artifactId>todo-console</artifactId> <version>1.0.0</version> <properties> <maven.compiler.release>17</maven.compiler.release> </properties> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <version>3.4.2</version> <configuration> <archive> <manifest> <mainClass>com.seuapp.todo.app.Main</mainClass> </manifest> </archive> </configuration> </plugin> </plugins> </build></project>Comandos de aceite (exemplos)
mvn -q clean packagedeve gerar o JAR emtarget/.java -jar target/todo-console-1.0.0.jardeve abrir o menu.
Se preferir Gradle, aplique o mesmo objetivo: compilar, empacotar e executar com um ponto de entrada definido.
Critérios de aceite (o que precisa estar funcionando)
| Item | Critério |
|---|---|
| Cadastro | Cria tarefa com ID único e valida título/prioridade |
| Listagem | Mostra todas as tarefas com campos essenciais |
| Busca por ID | Retorna tarefa ou mensagem “ID não encontrado” |
| Busca por termo | Lista tarefas cujo título contém o termo (case-insensitive) |
| Concluir | Marca como CONCLUIDA e persiste |
| Remover | Remove e persiste |
| Persistência | Ao reiniciar o app, dados anteriores continuam |
| Erros | Entradas inválidas não derrubam o programa; mensagens claras |
Implementando a busca por termo (exemplo)
Adicione no service um método que filtra a lista. Isso mantém a UI simples.
public List<Task> searchByTitle(String term) { if (term == null || term.trim().isEmpty()) throw new IllegalArgumentException("Informe um termo para busca"); String t = term.trim().toLowerCase(); return repo.findAll().stream() .filter(task -> task.getTitle().toLowerCase().contains(t)) .toList(); }Checklist de qualidade (revise antes de considerar “pronto”)
Organização e legibilidade
- Pacotes coerentes:
uinão acessa arquivo diretamente;repositorynão imprime no console. - Nomes claros:
markDone,searchByTitle,persist. - Mensagens padronizadas: prefixos como “Erro: …” e instruções curtas.
- Evite duplicação: parsing de
longe leitura de strings podem ser centralizados em umConsoleIO.
Validações e erros
- Título obrigatório e sem separador do CSV (
;). - Tratamento de
NumberFormatExceptionao ler IDs. - Tratamento de prioridade inválida (entrada fora do enum).
- Arquivo corrompido: se uma linha estiver inválida, decida se falha o carregamento inteiro (mais simples) ou se ignora a linha com aviso (mais resiliente).
Persistência
- Arquivo salvo em UTF-8.
- Diretório criado automaticamente.
- Regravação completa do arquivo após alterações (ok para pequeno volume).
Sugestões objetivas de melhorias (sem frameworks)
Refatoração e design
- Extrair ConsoleIO: métodos como
readLong(prompt),readNonBlank(prompt),readEnum(prompt, Class)para reduzir repetição na UI. - Exceções específicas: criar
NotFoundExceptioneValidationExceptionpara diferenciar erros de regra e de entrada. - Repositório mais eficiente: salvar incrementalmente (append) e reescrever apenas em operações que exigem (ex.: remoção), mantendo um índice em memória.
Testes básicos (sem bibliotecas externas, se quiser manter “puro”)
- Crie uma classe
TaskServiceSelfTestcom ummainque executa cenários e usaassert(habilitar com-ea). - Teste: criação valida/inválida, concluir inexistente, remoção, busca por termo.
public class TaskServiceSelfTest { public static void main(String[] args) { var repo = new InMemoryTaskRepository(); var service = new TaskService(repo); var t = service.create("Ler", "", com.seuapp.todo.model.Priority.MEDIA); assert t.getId() == 1; service.markDone(1); assert service.getById(1).getStatus() == com.seuapp.todo.model.Status.CONCLUIDA; } }Logs simples
- Use
java.util.loggingpara registrar eventos de persistência (carregou X tarefas, salvou arquivo, falha ao ler). - Evite logar entradas sensíveis (não aplicável aqui, mas é um hábito).
private static final java.util.logging.Logger LOG = java.util.logging.Logger.getLogger(FileTaskRepository.class.getName()); // LOG.info("Salvando em " + file);Robustez de dados
- Backup antes de sobrescrever: salvar em
tasks.csv.tmpe depois mover paratasks.csv. - Validação de integridade: impedir IDs duplicados no carregamento (detectar e falhar com mensagem clara).