Skip to content

Build a student enrollment app with JavaFX, Scene Builder and MySQL

A small desktop app that enrolls, lists, searches and deletes students — and teaches the four-step JDBC pattern you will reuse in every data app after it.

UXAtom Team

UXAtom Team

Engineering

11 min read
Share

Most first database apps go wrong in the same place. Not in the SQL, and not in the UI, but in the wiring between them: an fx:id that doesn't match its field, a module that isn't opened, a table that shows the right number of rows with every cell empty and no error to say why.

So this tutorial builds something small on purpose. A desktop app that manages student course enrollments, with four features:

  1. Enroll a student with an ID, name and course, validated first: every field required, no duplicate IDs
  2. List all students in a table
  3. Delete a student by ID
  4. Search for a student by ID

Small enough to finish in one sitting, but it touches every seam that matters:

  • Designing a UI visually in Scene Builder and connecting it to Java through FXML
  • Talking to a database with JDBC and PreparedStatement
  • Showing database rows in a JavaFX TableView
  • Validating input before it ever reaches the database

Tools: IntelliJ IDEA · Scene Builder · MySQL Workbench · JDK 17+

Step 1: Create the database

Open MySQL Workbench, connect to your local instance, open a new query tab (Ctrl+T) and run:

SQL
CREATE DATABASE enrollment_db;
USE enrollment_db;

CREATE TABLE students (
    student_id    VARCHAR(20)  PRIMARY KEY,
    student_name  VARCHAR(100) NOT NULL,
    course        VARCHAR(50)  NOT NULL
);

Refresh the Schemas panel and check that enrollment_db → students is there.

The PRIMARY KEY on student_id matters more than it looks. The app will check for duplicate IDs before inserting, but that check is for a friendly message. The key is what actually guarantees it.

Step 2: Create the JavaFX project

In IntelliJ, go to File → New → Project → JavaFX, choose Maven as the build system, and click Create.

IntelliJ generates four files: HelloApplication.java, HelloController.java, hello-view.fxml and module-info.java. You'll replace the contents of each one below.

Step 3: Add the MySQL driver

JDBC is only an interface. To talk to MySQL specifically, you need its driver. Add this to the <dependencies> block in pom.xml, then reload Maven:

XML
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <version>8.4.0</version>
</dependency>

Step 4: Update module-info.java

Java
module com.example.demo {
    requires javafx.controls;
    requires javafx.fxml;
    requires java.sql;                                   // needed for JDBC

    opens com.example.demo to javafx.fxml, javafx.base;  // needed for FXML + TableView
    exports com.example.demo;
}

Step 5: Design the UI

Open hello-view.fxml in Scene Builder (right-click the file → Open In SceneBuilder) and lay it out visually, or paste this in directly:

XML
<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.geometry.Insets?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>

<VBox spacing="12.0" xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1"
      fx:controller="com.example.demo.HelloController">
   <padding>
      <Insets top="15.0" right="15.0" bottom="15.0" left="15.0" />
   </padding>
   <children>
      <Label text="Student Course Enrollment System" style="-fx-font-size: 18px; -fx-font-weight: bold;" />

      <HBox spacing="10.0">
         <children>
            <TextField fx:id="idField" promptText="Student ID" />
            <TextField fx:id="nameField" promptText="Student Name" />
            <ComboBox fx:id="courseBox" prefWidth="160.0" promptText="Select Course" />
            <Button onAction="#enrollStudent" text="Enroll Student" />
         </children>
      </HBox>

      <HBox spacing="10.0">
         <children>
            <Button onAction="#viewAll" text="View All Members" />
            <Button onAction="#deleteStudent" text="Delete Member" />
         </children>
      </HBox>

      <HBox spacing="10.0">
         <children>
            <TextField fx:id="searchField" promptText="Search by Student ID" />
            <Button onAction="#searchStudent" text="Search Enrollment" />
            <Label fx:id="resultLabel" />
         </children>
      </HBox>

      <TableView fx:id="table" VBox.vgrow="ALWAYS">
         <columns>
            <TableColumn fx:id="idCol" prefWidth="150.0" text="Student ID" />
            <TableColumn fx:id="nameCol" prefWidth="300.0" text="Student Name" />
            <TableColumn fx:id="courseCol" prefWidth="200.0" text="Course" />
         </columns>
      </TableView>
   </children>
</VBox>

One rule holds the whole file together: every fx:id must match a field name in the controller, and every onAction="#method" must match a method name. FXML connects to Java by name alone, so a single typo is enough to break it.

Step 6: Write the code

Three classes: a model for one row, a controller for all the logic, and the application that starts it.

The model: Student.java

Java
package com.example.demo;

// One Student object = one row in the database
public class Student {
    private final String id;
    private final String name;
    private final String course;

    public Student(String id, String name, String course) {
        this.id = id;
        this.name = name;
        this.course = course;
    }

    // Getter names must match PropertyValueFactory names ("id" -> getId)
    public String getId()     { return id; }
    public String getName()   { return name; }
    public String getCourse() { return course; }
}

The getter names aren't a style choice. PropertyValueFactory<>("id") literally looks for a public method called getId.

The controller: HelloController.java

This is where everything happens. Change PASS to your MySQL root password.

Java
package com.example.demo;

import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.scene.control.*;
import javafx.scene.control.cell.PropertyValueFactory;

import java.sql.*;

public class HelloController {

    // ---------- STEP A: Database connection ----------
    private static final String URL  = "jdbc:mysql://localhost:3306/enrollment_db";
    private static final String USER = "root";
    private static final String PASS = "your_password";   // your MySQL root password

    private Connection connect() throws SQLException {
        return DriverManager.getConnection(URL, USER, PASS);
    }

    // ---------- STEP B: fx:id fields (names must match the FXML exactly) ----------
    @FXML private TextField idField;
    @FXML private TextField nameField;
    @FXML private ComboBox<String> courseBox;
    @FXML private TextField searchField;
    @FXML private Label resultLabel;
    @FXML private TableView<Student> table;
    @FXML private TableColumn<Student, String> idCol;
    @FXML private TableColumn<Student, String> nameCol;
    @FXML private TableColumn<Student, String> courseCol;

    // Runs automatically once after the FXML is loaded
    @FXML
    public void initialize() {
        courseBox.getItems().addAll("Java", "Python", "Data Structures");
        idCol.setCellValueFactory(new PropertyValueFactory<>("id"));
        nameCol.setCellValueFactory(new PropertyValueFactory<>("name"));
        courseCol.setCellValueFactory(new PropertyValueFactory<>("course"));
        viewAll();   // load data when the app opens
    }

    // ---------- STEP C: Requirement 1 — Enroll Student ----------
    @FXML
    private void enrollStudent() {
        String id = idField.getText().trim();
        String name = nameField.getText().trim();
        String course = courseBox.getValue();

        // Validation 1: all fields must be filled
        if (id.isEmpty() || name.isEmpty() || course == null) {
            showAlert("All fields must be filled!");
            return;
        }

        try (Connection con = connect()) {
            // Validation 2: unique Student ID
            PreparedStatement check = con.prepareStatement(
                    "SELECT student_id FROM students WHERE student_id = ?");
            check.setString(1, id);
            if (check.executeQuery().next()) {
                showAlert("Student ID already exists!");
                return;
            }

            PreparedStatement ps = con.prepareStatement(
                    "INSERT INTO students (student_id, student_name, course) VALUES (?, ?, ?)");
            ps.setString(1, id);
            ps.setString(2, name);
            ps.setString(3, course);
            ps.executeUpdate();

            showAlert("Student enrolled successfully!");
            idField.clear();
            nameField.clear();
            courseBox.setValue(null);
            viewAll();
        } catch (SQLException ex) {
            showAlert("Database error: " + ex.getMessage());
        }
    }

    // ---------- STEP D: Requirement 2 — View All Members ----------
    @FXML
    private void viewAll() {
        ObservableList<Student> list = FXCollections.observableArrayList();
        try (Connection con = connect()) {
            ResultSet rs = con.createStatement().executeQuery("SELECT * FROM students");
            while (rs.next()) {
                list.add(new Student(
                        rs.getString("student_id"),
                        rs.getString("student_name"),
                        rs.getString("course")));
            }
            table.setItems(list);
        } catch (SQLException ex) {
            showAlert("Database error: " + ex.getMessage());
        }
    }

    // ---------- STEP E: Requirement 3 — Delete Member ----------
    @FXML
    private void deleteStudent() {
        String id = idField.getText().trim();

        // Bonus: if the ID field is empty, use the selected table row
        Student selected = table.getSelectionModel().getSelectedItem();
        if (id.isEmpty() && selected != null) id = selected.getId();

        if (id.isEmpty()) {
            showAlert("Enter a Student ID (or select a row) to delete.");
            return;
        }
        try (Connection con = connect()) {
            PreparedStatement ps = con.prepareStatement(
                    "DELETE FROM students WHERE student_id = ?");
            ps.setString(1, id);
            int rows = ps.executeUpdate();
            showAlert(rows > 0 ? "Member deleted!" : "No member found with this ID.");
            idField.clear();
            viewAll();
        } catch (SQLException ex) {
            showAlert("Database error: " + ex.getMessage());
        }
    }

    // ---------- STEP F: Requirement 4 — Search Enrollment ----------
    @FXML
    private void searchStudent() {
        String id = searchField.getText().trim();
        if (id.isEmpty()) {
            showAlert("Enter a Student ID to search.");
            return;
        }
        try (Connection con = connect()) {
            PreparedStatement ps = con.prepareStatement(
                    "SELECT student_name, course FROM students WHERE student_id = ?");
            ps.setString(1, id);
            ResultSet rs = ps.executeQuery();
            if (rs.next()) {
                resultLabel.setText("Name: " + rs.getString("student_name")
                        + "  |  Course: " + rs.getString("course"));
            } else {
                resultLabel.setText("");
                showAlert("Student ID does not exist!");
            }
        } catch (SQLException ex) {
            showAlert("Database error: " + ex.getMessage());
        }
    }

    // ---------- Helper ----------
    private void showAlert(String msg) {
        Alert a = new Alert(Alert.AlertType.INFORMATION);
        a.setHeaderText(null);
        a.setContentText(msg);
        a.showAndWait();
    }
}

A few things worth noticing:

  • Validation happens in two layers. Empty fields are caught in Java before a connection is even opened. The duplicate ID needs the database, so it is checked there — with the primary key from step 1 as the backstop.
  • Every query uses ? placeholders. Values are bound with setString, never glued into the SQL string, so a name like O'Brien works and a malicious one can't rewrite the query.
  • try (Connection con = connect()) closes the connection for you, along with every statement and result set opened on it, even when something throws.
  • Delete has a quiet convenience: leave the ID field empty and it deletes whichever table row is selected.

The entry point: HelloApplication.java

Java
package com.example.demo;

import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.stage.Stage;

public class HelloApplication extends Application {
    @Override
    public void start(Stage stage) throws Exception {
        FXMLLoader loader = new FXMLLoader(HelloApplication.class.getResource("hello-view.fxml"));
        Scene scene = new Scene(loader.load(), 800, 500);
        stage.setTitle("Student Course Enrollment System");
        stage.setScene(scene);
        stage.show();
    }

    public static void main(String[] args) {
        launch();
    }
}

Click Run. The window opens with any existing students already in the table, because initialize() calls viewAll() on startup.

Try it out

Walk through these in order. Each one exercises a different branch of the controller:

Try thisWhat should happen
Click Enroll with an empty field"All fields must be filled!"
Enroll 101, Rahim, JavaSuccess, and the row appears in the table
Enroll 101 again"Student ID already exists!"
Search 101Name: Rahim | Course: Java
Search 999"Student ID does not exist!"
Delete 101"Member deleted!" and the row disappears

To confirm the data really reached the database, run SELECT * FROM students; in Workbench.

When it doesn't work

Nearly every failure in this app is one of these:

ErrorFix
No suitable driver foundAdd mysql-connector-j to pom.xml and reload Maven
Communications link failureThe MySQL service isn't running. Start it.
Access denied for user 'root'Wrong password in PASS
Public Key Retrieval is not allowedAppend ?allowPublicKeyRetrieval=true&useSSL=false to the URL
NullPointerException on a controlAn fx:id doesn't match the controller field name, or the @FXML annotation is missing
LoadExceptionAn onAction method name doesn't match the controller, or fx:controller has the wrong package
Table rows show but the cells are emptyGetter names don't match, the getters aren't public, or opens ... to javafx.base is missing

Notice that four of the seven are naming problems, not logic problems. That is the real lesson of FXML: it is wired by strings, so the compiler can't catch a mismatch for you.

The pattern to take away

Strip away the UI and every database action in this app is the same four steps:

Java
Connection con = connect();                              // 1. connect
PreparedStatement ps = con.prepareStatement("... ?");    // 2. prepare SQL
ps.setString(1, value);                                  // 3. bind values
ps.executeUpdate();   // INSERT / DELETE / UPDATE        // 4. execute
ps.executeQuery();    // SELECT, which returns a ResultSet

Once that clicks, a library catalog, a contact book or an inventory tracker is the same app with different column names.

Where to go next

Four upgrades, in the order worth doing them

0/4 Done

  • #Java
  • #JavaFX
  • #MySQL
  • #JDBC
Share

Related reading

Engineering8 min read

Interactive by exception

A static article ships no JavaScript for its body. Every live example changes that, so each one should be an argument the prose cannot make on its own.

Sofia LindqvistSofia Lindqvist
Engineering6 min read

Your component API is a contract, so write it down

Variant tables, prop naming and the boolean that should have been an enum. Notes from maintaining component libraries other people have to use.

Sofia LindqvistSofia Lindqvist

One useful email a month

Design system patterns, front-end techniques and notes from the learn platform. No promotions, no digest of other people’s links.

Unsubscribe anytime. We never share your address.