Session 8 · Programming Technology

Graphics, animation and a first database

Drawing on a panel with paintComponent and animating it with one Swing Timer that steps a Swing-free model; then a first database – JDBC with PreparedStatement, SQLite by default or MySQL in Docker, and a UI → backend → repository split that tests without a database.

20 min readpaintComponent and repaint()Timer-driven animationJDBC and PreparedStatementSQLite vs MySQL in DockerCode-first tablesRepository, model and DTO

Two new things this session, and both are the foundation of the third assignment. The first is elementary graphics: so far we built windows from buttons and labels; now we draw on a panel ourselves and animate the drawing with a Timer. The second is the database: the games of the third assignment save the player's result at the end and read back the top ten. Here we look at the smallest program that talks to a database; next session puts it into games.

The examples: UFOs (UFOs flying around a panel, everything in the view), UFOsMV (the same in model–view, with tests) and HighScores (a console leaderboard on top of SQLite or MySQL).

Two ideas carry the whole session: paintComponent only draws what the model says, and only the repository knows that a database exists. Everything else is detail.

Drawing on a panel: paintComponent

Until now components drew themselves. To show something else, extend JPanel and override paintComponent:

class DrawArea extends JPanel {
    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);               // clears the area with the background colour
        Graphics2D g2 = (Graphics2D) g;
        g2.setColor(Color.RED);
        g2.fillOval(100, 50, 200, 30);         // top-left corner (x, y), width, height
    }
}
Your codelistener, Timerrepaint()Swing (EDT)merges the requestsWindow eventsshow, resize, uncovercallspaintComponent(g)called by Swing, when it chooses1. super.paintComponent(g): clear2. draw what the model saysnever call paintComponent yourself
Fig. 1 · You ask, Swing decides. repaint() is a request; Swing calls paintComponent when it is ready – and also whenever the window needs it for its own reasons.
RuleWhy
You never call paintComponentSwing calls it whenever the component must be drawn: the first time, after a resize, when another window uncovered it, or when you asked.
If what you draw changed, call repaint()repaint() only requests a redraw; Swing soon calls paintComponent on the EDT. Several requests are merged into one.
Start with super.paintComponent(g)It clears the area. Without it the previous frame stays, and the UFOs leave trails.
Don't change the component while paintingNo setBackground in paintComponent: it requests another repaint, which calls paintComponent again… Draw the background as a filled rectangle.
Graphics2D is the pensetColor, fillOval, fillRect, drawLine, fillPolygon, drawString, drawImage; an antialiasing rendering hint smooths the edges.
xy(0, 0)(x, y)widthheightg2.fillOval(x, y, width, height)(0, 0) is the top-left corner,not the bottom-left: y grows down.(x, y) is the corner of the dashedbox, not the centre of the oval.
Fig. 2 · Screen coordinates, not maths coordinates. Every shape method takes the top-left corner of its bounding box.

Animation: one Timer, one step

An animation is many still frames in a row. A javax.swing.Timer calls a listener at a fixed interval – here every 30 ms, about 33 frames per second – and that listener steps the model and asks for a repaint:

timer = new Timer(30, e -> {
    space.step();          // the model: every UFO moves on
    drawArea.repaint();    // the view: ask for a redraw
});
timer.start();
Timer firesevery 30 ms (EDT)space.step()model: move, recolourrepaint()view: ask for a redrawpaintComponentdraws the model30 ms later: againUFOs used two Timers (moving, recolouring). UFOsMV uses one – step() does both.The Timer's listener runs on the EDT like a button's: no threads, no locks.
Fig. 3 · The game loop of every third-assignment game: the Timer is the clock, step() is the physics, paintComponent is the camera.

The Timer's listener runs on the event dispatch thread (EDT), just like a button's, so it may touch both the components and the model – there is no threading problem to solve. The third assignment's games (moving sprites, a clock) work exactly like this.

The mouse

Mouse events go to a component: MouseListener (click, press, release) and MouseMotionListener (move, drag). MouseAdapter implements both with empty methods, so you override only what you need. Dragging in the UFO program pulls every UFO to the mouse:

addMouseMotionListener(new MouseAdapter() {
    @Override
    public void mouseDragged(MouseEvent e) {
        space.moveAllTo(e.getX() - Ufo.WIDTH / 2.0, e.getY() - Ufo.HEIGHT / 2.0);
        repaint();
    }
});

The keyboard (WASD control) comes next session.

UFOs in model–view

The starting UFOs program works, but it repeats every mistake of the flashcards session:

  • The rules are in the view. How many UFOs there are, how a new one is made, how often one changes colour – all in UFOGUI. No test can reach them.
  • The model depends on AWT. UFO stores a java.awt.Color and draws itself onto a Graphics2D.
  • Three classes share one ArrayList (UFOGUI, DrawArea, the Timers) and none of them owns it.
  • Two Timers do what one could.
  • setBackground inside paintComponent – changing the component while drawing it.
UFOs – nobody owns the listUFOGUIDrawArea2 TimersArrayList<UFO>UFOsMV – Space owns itUfoGUIone Timer → step()DrawAreareads getUfos()Space – the modelprivate List<Ufo>, step()
Fig. 4 · A list that everyone changes has no rules. A list owned by the model changes only through the model's methods.

In UFOsMV, Space is the model: it owns the list, the size, the background colour and the rules (addUfos, removeUfos, step, moveAllTo, newBackground). It gets its Random from the constructor, so a test can fix the seed. step is one tick of "physics", without Swing:

public void step() {
    for (Ufo ufo : ufos) {
        ufo.move(width, height);                       // wraps around at the edges
        if (random.nextDouble() < RECOLOR_PROBABILITY) {
            ufo.setRgb(randomRgb());                   // 0xRRGGBB as an int, not a Color
        }
    }
}

The view reads the model in two places: in paintComponent (background, UFOs) and in the status bar (count). drawUfo is the only place that decides what a UFO looks like – the model doesn't know it is a dome on a saucer.

DrawArea.paintComponent – the view draws the model

@Override
protected void paintComponent(Graphics g) {
    super.paintComponent(g);
    Graphics2D g2 = (Graphics2D) g;
    g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
    g2.setColor(new Color(space.getBackgroundRgb()));   // the background is the model's, a filled rectangle
    g2.fillRect(0, 0, getWidth(), getHeight());
    for (Ufo ufo : space.getUfos()) {
        drawUfo(g2, ufo);                                 // the only place that knows what a UFO looks like
    }
}
UFOsUFOsMV
Owner of the listnobody – three classes change itSpace
Colourjava.awt.Color in the modelint (0xRRGGBB) in the model, Color in the view
Who draws a UFOthe UFO itselfDrawArea.drawUfo
BackgroundsetBackground in paintComponentthe model's colour, a filled rectangle
Timerstwo (move, recolour)one – step does both
Size of the spacethe Timer asks the panelthe model's; the panel calls space.resize
RandomnessMath.random()Random from the constructor
Testsnone21

Why a database?

In the third assignment the player's name and score are saved when the game ends, and a menu item shows the top ten. A text file could do that too, but:

A fileA database
you invent the format and write the parserthe structure (table, columns, types) is given and enforced
"top 10" = read everything, sort, cutSELECT … ORDER BY score DESC LIMIT 10 – the database's job
two programs writing at once corrupt itmany users at once, transactions
a crash halfway leaves a half-written filean operation happens completely or not at all

In bigger programs – web, mobile, enterprise – data practically always lives in a database. For the assignment you need the basics: one table, insert, query.

SQL in one minute

A relational database consists of tables. A table is like a spreadsheet: it has columns (with a name and a type) and rows (one row = one result). You talk to it in SQL:

-- Create the table (once). IF NOT EXISTS: no error if it's already there.
CREATE TABLE IF NOT EXISTS highscores (
    name       VARCHAR(50) NOT NULL,     -- text, at most 50 characters, required
    score      INTEGER     NOT NULL,     -- whole number
    created_at TIMESTAMP   NOT NULL      -- date and time
);

-- Insert a row
INSERT INTO highscores (name, score, created_at) VALUES ('Anna', 42, CURRENT_TIMESTAMP);

-- The top 10: highest score first, on a tie the earlier one wins
SELECT name, score, created_at
FROM highscores
ORDER BY score DESC, created_at ASC
LIMIT 10;

-- Delete every row
DELETE FROM highscores;

That is all the SQL the assignment needs. (If a task says "increase the winner's count, or insert a new row", you also need UPDATE – see next session.)

JDBC: how Java talks to a database

JDBC (Java Database Connectivity) is part of the JDK: one interface for every database. The database-specific part is the driver, a jar that Maven downloads from the pom.xml. The program is the same whether SQLite or MySQL is behind it – only the JDBC URL differs.

JdbcHighScoreRepositorythe same code for both databasesJDBC API – java.sql, part of the JDKDriverManager · Connection · PreparedStatement · ResultSetthe URL prefix picks the driverSQLite driver (a jar)jdbc:sqlite:highscores.dbMySQL driver (a jar)jdbc:mysql://localhost:3306/progtechhighscores.db – one fileMySQL server – in a container
Fig. 5 · The code above the line is written once. Below it, the URL decides which driver – and which kind of database – does the work.
Class (java.sql)What it is for
DriverManager.getConnection(url, user, password)opens a connection; the URL prefix (jdbc:sqlite:, jdbc:mysql:) selects the driver
Connectionone connection; must be closed → try-with-resources
PreparedStatementan SQL statement with ? placeholders; setString(1, name), setInt(2, score); executeUpdate() for INSERT/UPDATE/DELETE, executeQuery() for SELECT
ResultSetthe rows of a query, one at a time: while (rows.next()), rows.getString("name")
SQLExceptionchecked exception: the server isn't running, wrong password, no such table …

JdbcHighScoreRepository.save

public void save(HighScore highScore) throws StorageException {
    try (Connection connection = connect();
         PreparedStatement statement = connection.prepareStatement(
                 "INSERT INTO highscores (name, score, created_at) VALUES (?, ?, ?)")) {
        statement.setString(1, highScore.name());
        statement.setInt(2, highScore.score());
        statement.setTimestamp(3, Timestamp.valueOf(highScore.createdAt()));
        statement.executeUpdate();
    } catch (SQLException e) {
        throw new StorageException("Could not save the score: " + e.getMessage(), e);
    }
}

JdbcHighScoreRepository.findTop – the core

try (Connection connection = connect();
     PreparedStatement statement = connection.prepareStatement(SELECT_TOP)) {
    statement.setInt(1, limit);
    try (ResultSet rows = statement.executeQuery()) {
        while (rows.next()) {
            result.add(new HighScore(rows.getString("name"), rows.getInt("score"),
                    rows.getTimestamp("created_at").toLocalDateTime()));
        }
    }
} catch (SQLException e) {
    throw new StorageException("Could not read the leaderboard: " + e.getMessage(), e);
}

Every resource is opened in a try-with-resources header, so it is closed even when an exception flies. And the repository wraps SQLException into its own StorageException: the caller doesn't depend on java.sql and doesn't care whether the failure came from an SQL server or a file.

SQLite or MySQL?

The projects contain both, and the code is the same: three lines of Database.java decide which one is used.

SQLiteMySQL
What it isa file (highscores.db) in the project folder; the driver contains the whole database enginea server running in the background, accepting connections on a port
Installationnone: Maven downloads the drivera running server – for us, a Docker container
User, passwordnoneyes
Good forsingle-user desktop and mobile apps (browsers, phones, the assignment)many users, web and enterprise systems
In the assignmentthe default: always works, anywherean option: if Docker runs, one comment swap
// Database.java – SQLite (the default)
public static final String URL = "jdbc:sqlite:highscores.db";
public static final String USER = null;
public static final String PASSWORD = null;

// MySQL in Docker: comment out the three lines above, uncomment these
// public static final String URL = "jdbc:mysql://localhost:3306/progtech";
// public static final String USER = "progtech";
// public static final String PASSWORD = "progtech";

Each database stores values in its own way (SQLite keeps a timestamp as a number, MySQL as a date), but on the Java side getTimestamp returns the same LocalDateTime from both. Storage is the database's business; through JDBC the program sees one uniform picture.

Code-first: the program creates the table

The repository runs CREATE TABLE IF NOT EXISTS on its first connection. So nothing has to be prepared in the database: an empty SQLite file or a freshly started MySQL container is immediately fine.

private Connection connect() throws SQLException {
    Connection connection = DriverManager.getConnection(url, user, password);
    if (!tableChecked) {                       // code-first: once per repository
        try (Statement statement = connection.createStatement()) {
            statement.execute(CREATE_TABLE);   // CREATE TABLE IF NOT EXISTS ...
            tableChecked = true;
        } catch (SQLException e) {
            connection.close();
            throw e;
        }
    }
    return connection;
}
  • Anyone can start it who downloads the project: no SQL script to run, no database to click together. Including the person grading it.
  • The table's structure is in the code, versioned with it. A new column changes the code and the table together.
  • The tests run on a temporary, empty file – and the repository creates the table there too.

This is what frameworks like JPA/Hibernate or Entity Framework call "code-first", in its simplest form. If you want an id column (a leaderboard doesn't need one, a "real" table usually has one), the two databases spell it differently, which is why the example leaves it out:

-- SQLite
id INTEGER PRIMARY KEY AUTOINCREMENT,

-- MySQL
id INT AUTO_INCREMENT PRIMARY KEY,

MySQL in a Docker container

A database server is traditionally installed: an installer, a service always running in the background, config files, and traces left behind when you remove it. Docker runs the program in a container instead – an isolated, pre-packaged environment built from an image that contains MySQL and everything it needs. A container

  • starts with one command and is deleted with one, without a trace;
  • is the same for everyone: the MySQL server image gives the same server with the same settings on Windows, macOS and Linux;
  • is isolated: it touches nothing else on the machine, and you see only the port you publish (3306);
  • is not a virtual machine: no whole operating system runs in it, only the program, so it starts in seconds and uses little memory.

This is how development databases are run in industry today. Cloud providers offer managed MySQL too: there you also get a URL, a user and a password, and your program stays the same – only Database.java changes.

your computerJava programjdbc:mysql://localhost:3306-p 3306:3306host port → container portcontainer: progtech-mysqlMySQL serverlistens on 3306/var/lib/mysqlvolume: mysql-datacreated on the first start, from env:MYSQL_DATABASE=progtechMYSQL_USER=progtechMYSQL_PASSWORD=progtechdocker compose down -v deletes the volume too.
Fig. 6 · Three things to remember: the port mapping (how your program reaches the server), the environment variables (what the server creates on its first start) and the volume (where the data survives).

Starting it

Install Docker Desktop (free for personal and educational use; on Windows it uses WSL, which the installer enables), start it, and check that docker version prints both a Client and a Server part. Then start the container from the project's compose.yaml:

compose.yaml

services:
  mysql:
    image: mysql                 # the official MySQL server image
    container_name: progtech-mysql
    environment:
      MYSQL_ROOT_PASSWORD: root  # the admin password
      MYSQL_DATABASE: progtech   # created on the first start ...
      MYSQL_USER: progtech       # ... with a user who may do anything in it
      MYSQL_PASSWORD: progtech
    ports:
      - "3306:3306"              # host:container
    volumes:
      - mysql-data:/var/lib/mysql   # the data survives the container

volumes:
  mysql-data:
docker version                      # Client and Server both answer? Docker is running
docker compose up -d                # start the container described by compose.yaml
docker logs progtech-mysql          # wait for "ready for connections"
docker ps                           # running containers; PORTS shows 0.0.0.0:3306->3306/tcp
docker exec -it progtech-mysql mysql -u progtech -p   # a MySQL prompt inside the container
docker compose stop                 # stop – the data stays
docker compose down -v              # delete the container AND the volume – a clean slate

The same container without a compose file is a single (long) command; the compose file is the readable version of it, kept next to the project:

docker run --name progtech-mysql -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=progtech -e MYSQL_USER=progtech -e MYSQL_PASSWORD=progtech -p 3306:3306 -d mysql

On the first start the server needs 10–20 seconds to initialise; ready for connections at the end of docker logs means it's done. To look inside, you don't need a MySQL client on your machine – the container has one (password: progtech):

USE progtech;
SHOW TABLES;
SELECT * FROM highscores ORDER BY score DESC;
EXIT

Graphical tools work too (DBeaver, MySQL Workbench, the database windows of IntelliJ or NetBeans): host localhost, port 3306, database, user and password progtech. For the SQLite file, DB Browser for SQLite or DBeaver opens highscores.db directly.

When it doesn't connect

Message (from the SQLException)Cause and fix
Communications link failurethe container isn't running (docker ps) or isn't up yet (docker logs), or the port isn't 3306
Access denied for user 'progtech'wrong user or password – check the container's environment (docker inspect progtech-mysql)
Unknown database 'progtech'the container was started with a different MYSQL_DATABASE
port is already allocatedsomething else uses 3306 (another MySQL); stop it, or publish on -p 3307:3306 and use localhost:3307 in the URL
No suitable driver foundthe driver isn't on the classpath: is mysql-connector-j in the pom.xml? Reload Maven.
no such tablethe table wasn't created: does connect() run CREATE TABLE IF NOT EXISTS?
database is lockedSQLite: another program (a DB browser) holds the file open for writing – close one

Three layers: UI, backend, storage

HighScores is a console program: a menu to add a score, list the top ten and clear the board. It has three layers, and this structure carries straight into next session's games and into your assignment.

HighScores – console UIvalidates the input, prints DTOssave(name, score) · top(10) → HighScoreDtoLeaderboardthe backend: rules, model → DTOmodel: HighScorenever leaves the backendsave(HighScore) · findTop(limit)HighScoreRepositoryinterface – what the backend needsJdbcHighScoreRepositorythe only SQL; connects via DatabaseInMemoryRepositorya list – used by LeaderboardTestimplementstested on a temporary SQLite fileno database at all
Fig. 7 · The UI sees only the backend; the backend sees only an interface. Swap what is behind the interface and nothing above it changes – which is exactly how the backend gets tested without a database.
LayerClassJob
UIHighScoresthe console menu; calls only the backend and gets DTOs; no SQL, no connection, no SQLException
backendLeaderboardthe rules: which name is acceptable, when a score is made, what the rank is; save, top, clear
backendHighScorethe model: one result as the backend and the repository handle it – one row of the table (record)
backendHighScoreDtothe DTO: one row as the UI receives it, with its rank (record)
backendHighScoreRepositoryinterface: what the backend expects from storage – save, findTop, deleteAll
storageJdbcHighScoreRepositorythe interface with JDBC: the only class with SQL in it; creates the table
storageDatabasethe connection details in one place

The layers are wired together in main:

Leaderboard leaderboard = new Leaderboard(new JdbcHighScoreRepository());
leaderboard.save(name, score);
for (HighScoreDto highScore : leaderboard.top(10)) { ... }

Model and DTO: why two?

HighScore is the result as the backend and the repository handle it. HighScoreDto is what the UI receives: exactly what the display needs. Today they are nearly identical, but not the same:

  • The DTO has something the model doesn't: the rank. The table doesn't know which place a row is in; the backend computes it from the query result (toDto).
  • The model may hold things that don't belong on screen – in a bigger system internal ids, password hashes, audit data. The DTO carries none of them out.
  • If the model changes (a new column, different storage), the DTO – and with it the UI – can stay the same. The DTO is the contract between backend and UI.

Leaderboard – validation, model in, DTO out

public void save(String name, int score) throws StorageException {
    if (!isValidName(name)) {
        throw new IllegalArgumentException("Invalid name: '" + name + "'");
    }
    repository.save(new HighScore(name.strip(), score, LocalDateTime.now()));
}

static HighScoreDto toDto(int rank, HighScore highScore) {
    return new HighScoreDto(rank, highScore.name(), highScore.score(), highScore.createdAt());
}

Validation in two places

The UI checks that the name is not empty or too long (Leaderboard.isValidName) and that the score is a number; if not, it prints a message and doesn't call the backend. The backend checks the same again and throws IllegalArgumentException. That is the real protection, because this UI is not the only caller – a game's view, a test, later a web API. The UI's check is for the user (a sensible message, not an exception); the backend's is for the data.

Tests

Why is the repository an interface? Because then the backend can be tested without a database: LeaderboardTest gives the Leaderboard a repository backed by a list, and checks the name validation, the timestamp and the length of the list with no file and no server. This is the repository pattern of the Spring and .NET worlds, in its simplest form.

/** A repository in a list – instead of the JDBC one, for the test. */
private static class InMemoryRepository implements HighScoreRepository {
    private final List<HighScore> scores = new ArrayList<>();

    @Override
    public void save(HighScore highScore) {
        scores.add(highScore);
    }
    // findTop: sort the list, limit; deleteAll: clear
}

The SQL is tested separately by JdbcHighScoreRepositoryTest. Every test gets an empty, temporary SQLite file and deletes it at the end, so the tests never write into the real highscores.db and don't depend on each other. For that the repository has a second constructor that takes the URL, user and password.

@Before
public void createEmptyDatabase() throws IOException {
    file = Files.createTempFile("highscores-test", ".db");
    repository = new JdbcHighScoreRepository("jdbc:sqlite:" + file, null, null);
}

@After
public void deleteDatabase() throws IOException {
    Files.deleteIfExists(file);
}

The first test doubles as the code-first check (the empty file has no table; the repository creates it). Others check that a saved model comes back unchanged, the order and the LIMIT, that SQL typed into the name stays plain text, deleting, two repositories on one file seeing the same data, and that an unreachable server gives a StorageException, not an SQLException.

On the graphics side, UfoTest and SpaceTest check that a step adds the velocity, that a UFO wraps around at all four edges, that the same seed gives the same space, that after 10 000 steps every UFO is still inside, and that the list can't be modified from outside.

Practice

Task 1

Pause and speed

Add a Pause button to UFOsMV that stops and restarts the animation, and two buttons that make the animation faster and slower. Which class does each change belong to?

Approach

Both are about the clock, not the rules, so they stay in the view: timer.stop() / timer.start(), and timer.setDelay(…) for the speed. The model's step doesn't change at all.

pauseButton.addActionListener(e -> {
    if (timer.isRunning()) {
        timer.stop();
    } else {
        timer.start();
    }
});

Task 2

A new UFO every few seconds

Every 100th step a new UFO should appear. Implement it without a second Timer, and write a test that the count grows by exactly one after 100 steps.

Approach

A step counter in Space; step() checks it. In the test, create a Space with a fixed Random, call step() 99 times (no change), then once more.

private long steps;

public void step() {
    steps++;
    if (steps % SPAWN_PERIOD == 0) {
        addUfos(1);
    }
    // ... move and recolour as before
}

Task 3

A player's best score

Add a menu item to HighScores that asks for a name and prints that player's best score, or "no scores yet". Extend every layer, and test the backend with the in-memory repository.

Layer by layer

Interface first (findBest(name)), then the JDBC implementation with a ? for the name, then the in-memory fake in the test (a stream with filter and max), then Leaderboard.best(name) with the same name validation as save, and finally the menu. Note that MAX of no rows is SQL NULL – check rows.wasNull().

// HighScoreRepository
OptionalInt findBest(String name) throws StorageException;

// JdbcHighScoreRepository – the name goes in through ?, never by concatenation
"SELECT MAX(score) AS best FROM highscores WHERE name = ?"

Task 4

Switch to MySQL

Start the MySQL container, switch Database.java to MySQL, save a few scores, then look at the table from inside the container. Finally stop the container and see what the program says.

Commands

docker compose up -d in the project folder, wait for ready for connections in docker logs progtech-mysql, then docker exec -it progtech-mysql mysql -u progtech -p and SELECT * FROM highscores;. With the container stopped you should get a StorageException message, not a stack trace.

Summary

paintComponent
the method Swing calls to draw a component; you override it and never call it
repaint()
a request for a redraw; Swing calls paintComponent soon, on the EDT
Timer
calls a listener at a fixed interval on the EDT – the clock of an animation
JDBC
the java.sql interface to every database; the driver and the URL choose the database
PreparedStatement
an SQL statement with ? placeholders; values go in as data, never as SQL
Repository
an interface for storage; the JDBC class implements it, a test can fake it
DTO
the object the UI receives – exactly what the display needs, e.g. with the rank
Code-first
the program creates its own tables (CREATE TABLE IF NOT EXISTS)
  • paintComponent only draws the model; call repaint() when it changed, and start with super.paintComponent(g).
  • One Timer calls one step() in the model, then repaint() – everything that happens over time goes into step().
  • Positions and colours are numbers in the model; Color and drawing live in the view.
  • JDBC + PreparedStatement + try-with-resources; wrap SQLException into your own exception.
  • SQLite by default, MySQL in Docker as an option – only the URL changes.
  • UI → backend → repository interface → JDBC; the backend is tested with an in-memory repository.