Command Palette

Search for a command to run...

[Java Basics] A Student Manager Console App: The Capstone Project

Thirty-five articles have each taught one idea. This one puts them together into a program you can run: a console application that keeps a list of students, validates everything the user types, reports errors without dying, and saves its data to a text file so the next run picks up where the last one left off.

A console student manager: menu, list of students and the text file behind it

Nothing here is new. Classes, constructors, encapsulation, interfaces, exceptions, ArrayList, HashMap, Scanner and file I/O have all been covered. What is new is the assembly: which class owns which decision, where a check belongs, and what happens at the seams. That is the part a course of isolated examples never shows, and it is the part that makes the difference between code that works on the happy path and code that survives a user.

What we are building

Here is the finished program running. This is a real terminal session, captured from the program itself — the values after each prompt are what was typed:

=== Student Manager ===
No data in students.txt yet.

1) Add  2) List  3) Find  4) Update  5) Delete  6) Save  0) Exit
Choice: 1
Id: S001
Name: Alice Turner
Age: 20
GPA: 3.75
Added: S001   Alice Turner            20  3.75

1) Add  2) List  3) Find  4) Update  5) Delete  6) Save  0) Exit
Choice: 1
Id: S002
Name: Ben Carter
Age: 22
GPA: 3.10
Added: S002   Ben Carter              22  3.10

1) Add  2) List  3) Find  4) Update  5) Delete  6) Save  0) Exit
Choice: 1
Id: S003
Name: Chloe Adams
Age: 19
GPA: 3.92
Added: S003   Chloe Adams             19  3.92

1) Add  2) List  3) Find  4) Update  5) Delete  6) Save  0) Exit
Choice: 1
Id: S001
Name: Alice T.
Age: 21
GPA: 3.5
Error: student id S001 already exists

1) Add  2) List  3) Find  4) Update  5) Delete  6) Save  0) Exit
Choice: 1
Id: S004
Name: Dan Miller
Age: twenty
GPA: 3.0
Error: age: "twenty" is not a whole number

1) Add  2) List  3) Find  4) Update  5) Delete  6) Save  0) Exit
Choice: 2
ID     NAME                   AGE  GPA
S001   Alice Turner            20  3.75
S002   Ben Carter              22  3.10
S003   Chloe Adams             19  3.92
3 student(s)

1) Add  2) List  3) Find  4) Update  5) Delete  6) Save  0) Exit
Choice: 3
Id: S002
Found: S002   Ben Carter              22  3.10

1) Add  2) List  3) Find  4) Update  5) Delete  6) Save  0) Exit
Choice: 4
Id: S003
Current: S003   Chloe Adams             19  3.92
Name [Chloe Adams]: 
Age [19]: 21
GPA [3.92]: 3.95
Updated: S003   Chloe Adams             21  3.95

1) Add  2) List  3) Find  4) Update  5) Delete  6) Save  0) Exit
Choice: 5
Id: S001
Deleted S001

1) Add  2) List  3) Find  4) Update  5) Delete  6) Save  0) Exit
Choice: 2
ID     NAME                   AGE  GPA
S002   Ben Carter              22  3.10
S003   Chloe Adams             21  3.95
2 student(s)

1) Add  2) List  3) Find  4) Update  5) Delete  6) Save  0) Exit
Choice: 6
Saved 2 student(s) to students.txt

1) Add  2) List  3) Find  4) Update  5) Delete  6) Save  0) Exit
Choice: 0
Bye.

Five commands, two bad inputs rejected, one duplicate refused, and a data file on disk at the end. Everything below explains how it is put together.

The design

Four responsibilities, four kinds of class. The rule that decides where code goes is simple: each class should have one reason to change.

The classes of the project and which one depends on which

ClassResponsibility
Studentone student, and the rules about what a legal student is
StudentException and its subclassesthe ways this program can fail on purpose
StudentRepositorythe students currently in memory, and lookup by id
StudentStore / TextFileStoreturning that collection into a file and back
StudentManagerAppthe menu, the prompts, and nothing else

The arrows only point one way. Student knows nothing about the repository; the repository knows nothing about files; nothing except StudentManagerApp knows there is a console. That is what lets you swap the text file for a database later, or drive the same repository from a web interface, without touching the domain rules.

Student: the object that cannot be wrong

The most useful decision in the whole program is that a Student validates itself in its constructor and is immutable afterwards. Every field is final, there are no setters, and the constructor throws if any value is illegal. The consequence is worth stating plainly: if you are holding a Student, its values are legal. No other code has to check again.

// Student.java
import java.util.Locale;

/**
 * One student. Immutable: every field is validated once in the constructor,
 * so an object that exists is an object whose values are legal.
 */
public class Student {

    /** The one character a text field may not contain, so a student fits on one line. */
    public static final String SEPARATOR = "|";

    /** The same character as a regular expression: "|" alone means "or" to split(). */
    private static final String SEPARATOR_REGEX = "\\|";

    private static final int MIN_AGE = 16;
    private static final int MAX_AGE = 80;
    private static final double MIN_GPA = 0.0;
    private static final double MAX_GPA = 4.0;

    private final String id;
    private final String name;
    private final int age;
    private final double gpa;

    public Student(String id, String name, int age, double gpa) throws InvalidFieldException {
        this.id = requireText("id", id);
        this.name = requireText("name", name);
        this.age = requireAge(age);
        this.gpa = requireGpa(gpa);
    }

The validation helpers are private static because they are used before the object exists — they run while the constructor is still computing the values to assign:

    private static String requireText(String field, String value) throws InvalidFieldException {
        if (value == null || value.trim().isEmpty()) {
            throw new InvalidFieldException(field, "must not be empty");
        }
        if (value.contains(SEPARATOR)) {
            throw new InvalidFieldException(field, "must not contain " + SEPARATOR);
        }
        return value.trim();
    }

    private static int requireAge(int value) throws InvalidFieldException {
        if (value < MIN_AGE || value > MAX_AGE) {
            throw new InvalidFieldException("age",
                    value + " is outside " + MIN_AGE + ".." + MAX_AGE);
        }
        return value;
    }

    private static double requireGpa(double value) throws InvalidFieldException {
        if (value < MIN_GPA || value > MAX_GPA) {
            throw new InvalidFieldException("gpa",
                    value + " is outside " + MIN_GPA + ".." + MAX_GPA);
        }
        return value;
    }

Two more static methods handle the two places raw text arrives — the keyboard and the data file. Parsing lives here rather than in the console class so both entry points get the same rules and the same error messages:

    /** Builds a student from four raw strings, so the numbers are parsed in one place. */
    public static Student parse(String id, String name, String age, String gpa)
            throws InvalidFieldException {
        return new Student(id, name, parseAge(age), parseGpa(gpa));
    }

    /** Reads back one line written by toLine(). */
    public static Student fromLine(String line) throws InvalidFieldException {
        String[] parts = line.split(SEPARATOR_REGEX, -1);
        if (parts.length != 4) {
            throw new InvalidFieldException("format",
                    "expected 4 fields separated by " + SEPARATOR + ", found " + parts.length);
        }
        return parse(parts[0], parts[1], parts[2], parts[3]);
    }

Note split(SEPARATOR_REGEX, -1). The second argument keeps trailing empty fields, so a line ending in an empty GPA is a four-field line with a bad fourth field, not a three-field line. And the separator needs escaping because "|" alone means alternation to a regular expression — one of those details that silently does the wrong thing if you miss it.

A small exception hierarchy

Article 32 argued that a custom exception earns its place when a caller would treat it differently. Here, one common base type is what lets the console layer write a single catch that covers every deliberate failure, while the individual types still say precisely what went wrong:

// StudentException.java
/** Base type for every error this application raises on purpose. */
public abstract class StudentException extends Exception {

    protected StudentException(String message) {
        super(message);
    }

    protected StudentException(String message, Throwable cause) {
        super(message, cause);
    }
}
// InvalidFieldException.java
/** A field value the rules of the domain refuse to accept. */
public class InvalidFieldException extends StudentException {

    public InvalidFieldException(String field, String problem) {
        super(field + ": " + problem);
    }
}

// DuplicateIdException.java
/** An id that is already taken by another student. */
public class DuplicateIdException extends StudentException {

    public DuplicateIdException(String id) {
        super("student id " + id + " already exists");
    }
}

// StudentNotFoundException.java
/** An id that no student in the repository carries. */
public class StudentNotFoundException extends StudentException {

    public StudentNotFoundException(String id) {
        super("no student with id " + id);
    }
}

// StorageException.java
/** The data file could not be read or written, or it does not parse. */
public class StorageException extends StudentException {

    public StorageException(String message, Throwable cause) {
        super(message, cause);
    }
}

StorageException is the only one that takes a cause, because it is the only one that wraps a lower-level failure — an IOException from the file system, or an InvalidFieldException from a corrupt line. The others are the root cause.

The base class is abstract for the reason article 29 gave: nobody should ever throw a StudentException directly, because it would say nothing beyond "something went wrong".

The repository: a list and a map, kept in step

The repository holds the students. It needs two things at once — the order they were added, for listing, and instant lookup by id, for everything else. A list gives the first, a map gives the second, and the class exists to make sure the two never disagree.

public class StudentRepository {

    private final List<Student> students = new ArrayList<>();
    private final Map<String, Student> byId = new HashMap<>();

    public void add(Student student) throws DuplicateIdException {
        String id = student.getId();
        if (byId.containsKey(id)) {
            throw new DuplicateIdException(id);
        }
        students.add(student);
        byId.put(id, student);
    }

    public Student findById(String id) throws StudentNotFoundException {
        Student found = byId.get(id);
        if (found == null) {
            throw new StudentNotFoundException(id);
        }
        return found;
    }

Adding a student: parse, validate, check for a duplicate, then store in both structures

add checks the map, not the list, because containsKey is one step while scanning a list is not. findAll returns new ArrayList<>(students) — a copy. Article 37 will call the alternative a leaking getter: hand out the real list and any caller can add a student that never passed through add, and the map would not know about it.

    public void update(Student updated) throws StudentNotFoundException {
        String id = updated.getId();
        int index = indexOf(id);
        if (index < 0) {
            throw new StudentNotFoundException(id);
        }
        students.set(index, updated);
        byId.put(id, updated);
    }

    public void remove(String id) throws StudentNotFoundException {
        int index = indexOf(id);
        if (index < 0) {
            throw new StudentNotFoundException(id);
        }
        students.remove(index);
        byId.remove(id);
    }

    /** A copy, so a caller cannot add or remove behind the repository's back. */
    public List<Student> findAll() {
        return new ArrayList<>(students);
    }

    private int indexOf(String id) {
        for (int i = 0; i < students.size(); i++) {
            if (students.get(i).getId().equals(id)) {
                return i;
            }
        }
        return -1;
    }
}

update and remove write to both structures. This is the honest cost of keeping two views of the same data, and it is exactly why they live behind a class instead of being two fields in main.

Persistence behind an interface

The application should not care that students are stored in a text file. So it does not: it depends on an interface with two methods.

// StudentStore.java
import java.util.List;

/** Where the students live between two runs of the program. */
public interface StudentStore {

    void save(List<Student> students) throws StorageException;

    List<Student> load() throws StorageException;
}

The implementation is where files actually happen — try-with-resources, an explicit charset, and one student per line:

public class TextFileStore implements StudentStore {

    private final Path path;

    public TextFileStore(String fileName) {
        this.path = Path.of(fileName);
    }

    @Override
    public void save(List<Student> students) throws StorageException {
        try (BufferedWriter out = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
            for (Student student : students) {
                out.write(student.toLine());
                out.newLine();
            }
        } catch (IOException e) {
            throw new StorageException("cannot write " + path, e);
        }
    }

    @Override

Reading back is longer because reading is where things go wrong. A line that will not parse is translated into a StorageException carrying the line number, which is the piece of information the user actually needs:

    public List<Student> load() throws StorageException {
        List<Student> loaded = new ArrayList<>();
        if (!Files.exists(path)) {
            return loaded;
        }
        try (BufferedReader in = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
            String line;
            int number = 0;
            while ((line = in.readLine()) != null) {
                number++;
                if (line.trim().isEmpty()) {
                    continue;
                }
                try {
                    loaded.add(Student.fromLine(line));
                } catch (InvalidFieldException e) {
                    throw new StorageException(path + " line " + number + ": " + e.getMessage(), e);
                }
            }
        } catch (IOException e) {
            throw new StorageException("cannot read " + path, e);
        }
        return loaded;
    }

StandardCharsets.UTF_8 is passed explicitly on both sides. Article 35 explained why: the default became UTF-8 in Java 18, but saying so in the code is what makes it true on every JDK and every machine.

After a save, the file looks like this:

S002|Ben Carter|22|3.1
S003|Chloe Adams|21|3.95

One detail worth noticing: the GPA typed as 3.10 is stored as 3.1. toLine() concatenates a double, and 3.10 and 3.1 are the same number — the trailing zero was never data, only formatting. The listing shows 3.10 because toString() formats with %.2f. Storage keeps values; presentation adds zeros.

The menu loop

Everything the user sees lives in one class, and each menu item is one small method.

    public void run() {
        System.out.println("=== Student Manager ===");
        loadAtStartup();
        boolean running = true;
        while (running) {
            printMenu();
            String choice = ask("Choice: ");
            if (inputEnded) {
                System.out.println("(input ended)");
                break;
            }
            switch (choice) {
                case "1" -> addStudent();
                case "2" -> listStudents();
                case "3" -> findStudent();
                case "4" -> updateStudent();
                case "5" -> deleteStudent();
                case "6" -> saveToFile();
                case "0" -> running = false;
                default -> System.out.println("Unknown choice: " + choice);
            }
        }
        System.out.println("Bye.");
    }

The menu loop: print the menu, read a choice, dispatch, come back

The switch uses arrow labels, so no break is needed and no case can fall through into the next. default catches typos instead of ignoring them.

Each command follows the same shape: gather input, do the work, catch StudentException, print the message. One catch in each method, at the level that knows how to talk to the user:

    private void addStudent() {
        try {
            String id = ask("Id: ");
            String name = ask("Name: ");
            String age = ask("Age: ");
            String gpa = ask("GPA: ");
            Student student = Student.parse(id, name, age, gpa);
            repository.add(student);
            System.out.println("Added: " + student);
        } catch (StudentException e) {
            System.out.println("Error: " + e.getMessage());
        }
    }

That is the whole error strategy. A bad value costs the user one line of output and one retry. It never costs them the program, and it never costs them the students they have already entered.

Reading input without tripping over Scanner

Two details in ask are worth more than they look:

    private String ask(String label) {
        System.out.print(label);
        System.out.flush();          // print() does not flush on its own, and hasNextLine()
                                     // blocks, so without this the prompt appears too late
        if (!in.hasNextLine()) {
            inputEnded = true;
            System.out.println();
            return "";
        }
        return in.nextLine().trim();
    }

System.out.flush() is there because print does not flush on its own — only a newline triggers that. Without the flush, the prompt sits in a buffer while the program blocks waiting for input, and the user stares at a blank line wondering what is expected.

hasNextLine() is there because input can end. If the user presses Ctrl-D, or you run the program with its input redirected from a file, nextLine() has nothing to return. Without the check it throws, and the program dies with a stack trace:

Exception in thread "main" java.util.NoSuchElementException: No line found
	at java.base/java.util.Scanner.nextLine(Scanner.java:1660)
	at StudentManagerApp.ask(StudentManagerApp.java:139)
	at StudentManagerApp.addStudent(StudentManagerApp.java:70)
	at StudentManagerApp.run(StudentManagerApp.java:32)
	at StudentManagerApp.main(StudentManagerApp.java:20)

With the check, the same run ends politely:

=== Student Manager ===
Loaded 2 student(s) from students.txt

1) Add  2) List  3) Find  4) Update  5) Delete  6) Save  0) Exit
Choice: Id: Name: 
Age: 
GPA: 
Error: age: "" is not a whole number

1) Add  2) List  3) Find  4) Update  5) Delete  6) Save  0) Exit
Choice: 
(input ended)
Bye.

Notice the whole program is reading through a single Scanner, created once in main inside a try-with-resources and passed to the constructor. Creating a second Scanner on System.in is a classic way to lose input, because each one buffers ahead independently.

Running it

Compile everything in one command and run the app:

javac *.java
java StudentManagerApp

The second run proves the file did its job — the students come back without being retyped:

=== Student Manager ===
Loaded 2 student(s) from students.txt

1) Add  2) List  3) Find  4) Update  5) Delete  6) Save  0) Exit
Choice: 2
ID     NAME                   AGE  GPA
S002   Ben Carter              22  3.10
S003   Chloe Adams             21  3.95
2 student(s)

1) Add  2) List  3) Find  4) Update  5) Delete  6) Save  0) Exit
Choice: 3
Id: S003
Found: S003   Chloe Adams             21  3.95

1) Add  2) List  3) Find  4) Update  5) Delete  6) Save  0) Exit
Choice: 0
Bye.

Bad values are refused one at a time, and the program keeps going:

=== Student Manager ===
Loaded 2 student(s) from students.txt

1) Add  2) List  3) Find  4) Update  5) Delete  6) Save  0) Exit
Choice: 1
Id: S004
Name: Le|Minh
Age: 20
GPA: 3.0
Error: name: must not contain |

1) Add  2) List  3) Find  4) Update  5) Delete  6) Save  0) Exit
Choice: 1
Id: S004
Name: Dan Miller
Age: 20
GPA: 4.5
Error: gpa: 4.5 is outside 0.0..4.0

1) Add  2) List  3) Find  4) Update  5) Delete  6) Save  0) Exit
Choice: 0
Bye.

The first of those is the separator rule earning its keep. Without it, a name containing | would write a line with five fields that could never be read back — the data would be corrupted at save time and the failure would only appear on the next run.

When the data file is broken

Files get edited by hand, truncated by a full disk, or merged badly. Both cases below are a real run against a file whose third line was damaged:

=== Student Manager ===
Error: students.txt line 3: format: expected 4 fields separated by |, found 3
Starting with an empty list.

1) Add  2) List  3) Find  4) Update  5) Delete  6) Save  0) Exit
Choice: 0
Bye.
=== Student Manager ===
Error: students.txt line 3: gpa: "abc" is not a number
Starting with an empty list.

1) Add  2) List  3) Find  4) Update  5) Delete  6) Save  0) Exit
Choice: 0
Bye.

The line number comes from the counter in load(), and the rest of the message comes from the InvalidFieldException that Student.fromLine threw, wrapped as the cause. The program reports what is wrong and where, then starts empty rather than exiting — a decision, not an accident. If you would rather refuse to start than risk overwriting a file you could not read, that is one System.exit(1) away, and for real data it is often the better choice.

Extend it yourself

The program is deliberately small enough to change. Each of these is a genuine exercise, in rough order of difficulty:

  1. Sort the listing. Add a menu item that lists students by GPA, highest first. The list is already there; you need a comparison and a copy so the stored order is not disturbed.
  2. Search by name. Add a partial, case-insensitive name search. Think about why this one has to scan the list while findById does not.
  3. Save on exit. Ask the user whether to save when they choose 0, and track whether anything actually changed since the last save.
  4. A second store. Write a CsvStore that implements StudentStore with proper quoting, so a name containing the separator is legal. Nothing outside the store class should change — that is the test of whether the interface was drawn in the right place.
  5. A class of students. Add a Course that holds students and a maximum size, and give Student a list of the courses it is enrolled in. This is where you will discover why two objects pointing at each other is harder than it looks.

Common mistakes when assembling a program like this

MistakeWhat goes wrong
Validating in the console classthe rule holds only for input typed at the menu, not for data loaded from the file
Mutable Student with settersan object can become illegal after construction, so every reader must re-check
Returning the internal list from findAllcallers can bypass add, and the map silently goes stale
Catching Exception in the menua typo in your own code gets reported as if the user caused it
One Scanner per methodeach one buffers ahead, and input disappears
No charset on the filethe data file stops round-tripping the moment a name has an accent
Storing formatted text3.10 written as text is presentation; 3.1 is the value

Frequently asked questions

Why is Student immutable when the program has an Update command?

Update builds a new Student from the edited values and replaces the old one in the repository. That way the new values go through exactly the same constructor validation as a brand-new student, and a failed edit leaves the original untouched. Mutating in place would require validating in the setters and would still leave a window where the object is half-updated.

Why an interface for the store when there is only one implementation?

Because it marks the boundary. StudentManagerApp is written against StudentStore, so nothing in it can accidentally depend on files. Exercise 4 above is the payoff: a second implementation should require no changes anywhere else. If it does, the interface was not the real boundary.

Should the repository use only a Map?

It could, and lookups would be identical. You would lose the insertion order that the listing relies on — HashMap gives no ordering guarantee at all, as article 34 showed. Keeping a list alongside is one way to have both; LinkedHashMap is another, and a good thing to look up once this program feels comfortable.

Why does the program keep running after a bad line in the data file?

Because a menu-driven tool that refuses to start is useless to someone who needs to fix the file. The message names the line, so the user can open it in an editor. Choose differently when the data matters more than the convenience — a payroll tool should stop.

What this program demonstrates

Every idea in this course appears here, doing real work rather than illustrating itself. Classes and constructors give Student a shape. Encapsulation and final make it trustworthy. An interface separates the storage decision from the storage mechanism. A small exception hierarchy turns failures into information. ArrayList and HashMap hold the data in the two shapes the program needs. Scanner and file I/O connect it to a keyboard and a disk.

What ties them together is a single habit: decide where each piece of knowledge lives, and let nothing else duplicate it. The rules about a legal student live in Student. The rule that ids are unique lives in the repository. The knowledge that data is a |-separated text file lives in TextFileStore. When a rule changes, exactly one file changes.

The final article closes the course by making that habit explicit: naming, method size, guard clauses, the traps that javac will not warn you about, and the practices worth carrying into every program you write from here.

Related Posts

[Java Basics] Setting Up a Java IDE and Creating Your First Project

Compare IntelliJ IDEA, Eclipse and VS Code for Java, install one, create your first Java project, understand the src/out layout, and fix the package-does-not-match-directory error.

[Java Basics] Nested Loops, break and continue in Java

Nested loops in Java and the two keywords that cut them short: how many times the inner body runs, break leaving only the innermost loop, continue skipping the update in a while loop, labelled break and continue, and the switch-inside-a-loop trap.

[Java Basics] Reading Input in Java with Scanner (and the nextLine Trap)

How to read keyboard input in Java with Scanner: nextInt vs nextLine, the empty-string trap, InputMismatchException, hasNextInt validation, the nextDouble locale trap, and printf formatting.

[Java Basics] Classes and Objects in Java: The Foundation of OOP

Classes and objects in Java, proved by running the code: what a class defines, what new really does step by step, zero-initialised fields, references and aliasing, null and the helpful NullPointerException, instance methods, toString, equals versus ==, and the one-class-per-file rule, all on OpenJDK 21.