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:
- Enroll a student with an ID, name and course, validated first: every field required, no duplicate IDs
- List all students in a table
- Delete a student by ID
- 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:
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:
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.4.0</version>
</dependency>
Step 4: Update module-info.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 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
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.
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 withsetString, never glued into the SQL string, so a name likeO'Brienworks 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
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 this | What should happen |
|---|---|
| Click Enroll with an empty field | "All fields must be filled!" |
Enroll 101, Rahim, Java | Success, and the row appears in the table |
Enroll 101 again | "Student ID already exists!" |
Search 101 | Name: 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:
| Error | Fix |
|---|---|
No suitable driver found | Add mysql-connector-j to pom.xml and reload Maven |
Communications link failure | The MySQL service isn't running. Start it. |
Access denied for user 'root' | Wrong password in PASS |
Public Key Retrieval is not allowed | Append ?allowPublicKeyRetrieval=true&useSSL=false to the URL |
NullPointerException on a control | An fx:id doesn't match the controller field name, or the @FXML annotation is missing |
LoadException | An onAction method name doesn't match the controller, or fx:controller has the wrong package |
| Table rows show but the cells are empty | Getter 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:
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
Related reading
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.
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.
Performance budgets belong in CI, not in a retrospective
A Lighthouse score is a snapshot. A budget that fails the build is a constraint. Here is the setup we ship with every front-end.
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.

