REST API with Spring Boot and Oracle DB

Build a task tracker REST API backed by a real Oracle Database in Docker.

Introduction

30 Second Summary

Every software company needs a system for tracking who is working on what. When dozens of engineers are spread across multiple teams, a reliable task tracker keeps projects moving forward and deadlines visible.

In this project, you will build a REST API that manages engineers, teams, and tasks using Spring Boot and Oracle Database. You will design a relational schema, write custom SQL queries, and publish professional code to GitHub.

What You'll Build

A Cross-Team Task Tracker REST API that models how engineering organizations manage work across teams, built with Spring Boot and Oracle Database.

By the end of this project, you'll have:

  • A working REST API with endpoints for creating, reading, updating, and deleting teams, engineers, and tasks.
  • A relational database schema in Oracle with foreign keys connecting engineers to teams and tasks to assignees.
  • A professionally structured Java project using the Controller-Service-Repository pattern with JUnit 5 unit tests.
  • Secret Mission: Add a Sprint Analytics endpoint that returns task completion metrics per team using SQL aggregation queries.

Are there any prerequisites?

You should have basic Java knowledge (classes, interfaces, annotations). No prior SQL or Spring Boot experience is required.

Before We Start

Before We Start...

Set Up Your Development Environment

Before you can build the Task Tracker API, you need the right tools on your machine. Your project requires a specific version of Java to compile the code and Docker to run the database.

In this step, you will install JDK 21, set up Docker Desktop, and generate a ready-to-go Spring Boot project with all the dependencies pre-configured.

In this step, get ready to:
  • Install and verify JDK 21.
  • Install and verify Docker Desktop.
  • Generate and import a Spring Boot project with the correct dependencies.
Install and verify JDK 21

Spring Boot 3.5 requires Java 17 or higher, and this project targets Java 21 specifically. JDK 21 is a Long-Term Support release, making it a stable choice for enterprise development.

  • Check if JDK 21 is already installed by running this command in your terminal:
java --version

✔️ I see version 21 or higher

Your JDK is ready to go. Move on to the next substep.

ⓧ I see an older version

You have Java installed, but you need version 21 for this project. The simplest path is to install Adoptium Temurin JDK 21 alongside your existing version.

  • Visit the Adoptium Temurin releases page.
  • Select JDK 21 and download the installer for your operating system (.msi for Windows, .pkg for macOS).
  • Run the installer and follow the prompts to complete the installation.
  • Close and reopen your terminal, then run java --version again to confirm it now shows version 21.

macOS users with multiple Java versions

If your terminal still shows an older version after installing, the system may be pointing to the old JDK. Run /usr/libexec/java_home -V to see all installed JDKs, then set JAVA_HOME to the JDK 21 path in your shell profile.

ⓧ Command not found

Java is not installed on your machine yet. You will install Adoptium Temurin JDK 21, a free and open-source distribution.

  • Visit the Adoptium Temurin releases page.
  • Select JDK 21 and download the installer for your operating system (.msi for Windows, .pkg for macOS).
  • Run the installer and follow the prompts. On Windows, make sure the option to set JAVA_HOME is checked.
  • Close and reopen your terminal, then run java --version to confirm it shows version 21.

Still seeing 'command not found'?

Make sure you closed and reopened your terminal after the installation. Your terminal needs a fresh session to detect newly installed programs.

help me troubleshoot why java --version is not working after installing JDK 21

Install and verify Docker Desktop

Docker lets you run Oracle Database inside a container on your local machine. This avoids a complex database installation and keeps your system clean.

  • Check if Docker is already installed by running this command:
docker --version

✔️ I see a version number

Docker is installed and ready. Move on to the next substep.

  • Make sure Docker Desktop is running by looking for the Docker whale icon in your system tray (Windows) or menu bar (macOS).

ⓧ Command not found

Docker Desktop is not installed yet. It provides both the Docker engine and a graphical dashboard for managing containers.

  • Visit the Docker Desktop download page.
  • Download the installer for your operating system (.exe for Windows, .dmg for macOS).
  • Run the installer and follow the prompts. Accept the default settings.
  • Once installed, launch Docker Desktop and wait for the engine to start. You will see a green status indicator when it is ready.
  • Open a new terminal and run docker --version to confirm the installation.

Docker command still not recognized?

On Windows, you may need to restart your computer after installing Docker Desktop. On macOS, make sure you dragged the Docker app into your Applications folder and launched it at least once.

help me get Docker Desktop working on my machine

Generate your Spring Boot project

Spring Initializr is a web tool that generates a ready-to-build Spring Boot project with your chosen dependencies. You will use it to scaffold the task tracker project structure.

  • Open start.spring.io in your browser.
  • Set Project to Maven.
  • Set Language to Java.
  • Set Spring Boot to 3.5.x (select the latest 3.5 version available).
  • Under Project Metadata, enter com.tasktracker in the Group field.
  • Enter task-tracker-api in the Artifact field.
  • Enter Cross-Team Task Tracker REST API with Oracle Database in the Description field.
  • Set Packaging to Jar.
  • Set Java to 21.

What do Group and Artifact mean?

Group is the reverse-domain identifier for your organization or project (like a Java package root). Artifact is the name of your specific project.

Together they form a unique coordinate that identifies your project in the Maven ecosystem.

  • Click ADD DEPENDENCIES and search for each of these dependencies one at a time:
  • Spring Web (provides the REST controller framework).
  • Spring Data JPA (handles database access with JPA and Hibernate).
  • Oracle Driver (the JDBC driver for connecting to Oracle Database).
  • Spring Boot DevTools (enables automatic restarts during development).
  • Validation (adds request body validation annotations).
  • Click GENERATE to download the project as a .zip file.
  • Extract the zip file to your Desktop.

What did Spring Initializr generate?

The zip contains a complete Maven project structure with a pom.xml (dependency configuration), a main application class, and the standard src/main/java and src/test/java folder hierarchy.

  • Open IntelliJ IDEA.
  • Click Open on the welcome screen.
  • Navigate to the task-tracker-api folder on your Desktop and click OK.
  • If prompted to trust the project, click Trust Project.
  • Wait for IntelliJ to finish indexing and downloading dependencies. You will see the progress bar at the bottom disappear when it is complete.

Verify the project imported correctly by expanding the file tree in the Project panel on the left. You should see pom.xml at the root and TaskTrackerApiApplication.java inside src/main/java/com/tasktracker/.

IntelliJ not recognizing the project?

If you see red underlines or missing imports, IntelliJ may still be downloading dependencies. Check the bottom status bar for a progress indicator and wait for it to finish.

If dependencies never resolve, right-click pom.xml in the Project panel and select Maven then Reload project.

help me fix IntelliJ IDEA not recognizing my Spring Boot Maven project

✔️ Awesome, I've got everything!

Great. Double check you can see pom.xml and TaskTrackerApiApplication.java in IntelliJ with no red errors, then move on.

ⓧ I'd like to double check the full code

Here is what your pom.xml should look like. The Spring Boot version may differ slightly depending on what was available on the Initializr (e.g. 3.5.14 instead of 3.5.0). That is fine.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.0</version>
        <relativeTo/>
    </parent>
    <groupId>com.tasktracker</groupId>
    <artifactId>task-tracker-api</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>task-tracker-api</name>
    <description>Cross-Team Task Tracker REST API with Oracle Database</description>
    <properties>
        <java.version>21</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>com.oracle.database.jdbc</groupId>
            <artifactId>ojdbc11</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

And here is your main application class:

package com.tasktracker;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class TaskTrackerApiApplication {
    public static void main(String[] args) {
        SpringApplication.run(TaskTrackerApiApplication.class, args);
    }
}

Your development environment is ready with JDK 21, Docker Desktop, and a fresh Spring Boot project. Next up, you will spin up an Oracle Database inside a Docker container and connect your application to it.

Run Oracle Database in Docker

Your development tools are ready. Now you need a database for your API to talk to.

Instead of installing Oracle Database directly on your machine, you will run it inside a Docker container. This keeps your system clean and makes the database disposable. In this step, you will pull the Oracle Database Free image, create a dedicated application user, and configure Spring Boot to connect.

In this step, get ready to:
  • Pull and start the Oracle Database Free container.
  • Create an application user inside the database.
  • Configure Spring Boot to connect to Oracle.
Pull and start the Oracle Database Free container

Oracle publishes a free container image of their database to the Oracle Container Registry. You need an Oracle account to pull it.

  • Go to container-registry.oracle.com in your browser.
  • Sign in with your Oracle account. If you do not have one, click Create New Oracle Account and register for free.
  • After signing in, click Database in the product list.
  • Select free and accept the license agreement.

Why do I need to accept a license?

Oracle requires you to accept their terms before pulling container images. This is a one-time step. Once accepted, you can pull the image from your terminal.

  • Back in your terminal, log in to the Oracle Container Registry by running this command:
docker login container-registry.oracle.com

What credentials do I enter?

When prompted, enter your Oracle account email as the username and your account password or Auth Token as the password.

If your password is rejected, you may need to generate an Auth Token from your Oracle account security settings and use that instead.

  • Pull and start the Oracle Database Free container by running this command:
docker run -d --name oracle-free -p 1521:1521 -e ORACLE_PWD="YourPassword123" container-registry.oracle.com/database/free:latest-lite

What does this command do?

  • `-d` runs the container in the background so you can keep using your terminal.
  • `--name oracle-free` gives the container a memorable name you can reference later.
  • `-p 1521:1521` maps the database port inside the container to the same port on your machine.
  • `-e ORACLE_PWD="YourPassword123"` sets the password for the system administrator accounts (SYS, SYSTEM, PDBADMIN).
  • The latest-lite tag avoids a known bug in the latest tag that causes memory protection key errors.

The first time you run this, Docker downloads the image (about 1.5 GB). This takes a few minutes depending on your internet speed.

  • Wait for the database to finish starting up by checking the container logs:
docker logs -f oracle-free

Watch the output until you see DATABASE IS READY TO USE! in the logs. Then press Ctrl+C to stop following the logs.

Container exiting immediately?

Make sure Docker Desktop is running. Open Docker Desktop and check that the engine status shows a green icon.

If you see a memory protection key error, confirm you are using the latest-lite tag, not latest.

Help me troubleshoot my Oracle Docker container

Create an application user in Oracle

Oracle Database uses a Pluggable Database (PDB) architecture. The container creates a default PDB called FREEPDB1. You will create a dedicated user inside this PDB for your Spring Boot app to connect with.

  • Connect to the Oracle database inside the container by running this command:
docker exec -it oracle-free sqlplus sys/YourPassword123@//localhost:1521/FREEPDB1 as sysdba

What does this command do?

  • `docker exec -it oracle-free` opens an interactive session inside the running container.
  • `sqlplus` is Oracle's command-line tool for running SQL.
  • `sys/YourPassword123@//localhost:1521/FREEPDB1 as sysdba` connects as the system administrator directly to the FREEPDB1 pluggable database.

You should see a SQL> prompt. This means you are connected to Oracle.

  • Create the application user and grant it permissions by running these SQL statements:
CREATE USER tasktracker IDENTIFIED BY tasktracker123;
GRANT CONNECT, RESOURCE, UNLIMITED TABLESPACE TO tasktracker;

What do these statements do?

`CREATE USER tasktracker IDENTIFIED BY tasktracker123` creates a new database user named tasktracker with the password tasktracker123.

`GRANT CONNECT, RESOURCE, UNLIMITED TABLESPACE` gives this user permission to log in, create tables, and use storage space. These are the minimum permissions your Spring Boot app needs to create and manage tables.

  • Verify the user was created successfully by running:
SELECT username FROM all_users WHERE username = 'TASKTRACKER';

You should see TASKTRACKER in the output.

  • Exit SQL*Plus by typing:
EXIT;

Seeing ORA-12514 or cannot connect?

The database may still be starting up. Wait another 30-60 seconds and try the docker exec command again.

Confirm the container is healthy by running docker ps and checking that oracle-free shows status Up.

Help me fix my Oracle Docker connection issue

Configure Spring Boot to connect to Oracle

Spring Boot needs to know where your database lives and how to authenticate. You configure this in the application.properties file.

  • In IntelliJ IDEA, expand src/main/resources in the Project sidebar.
  • Open application.properties (it should already exist but be empty).
  • Add the database connection configuration by pasting this code:
spring.datasource.url=jdbc:oracle:thin:@localhost:1521/FREEPDB1
spring.datasource.username=tasktracker
spring.datasource.password=tasktracker123
spring.datasource.driver-class-name=oracle.jdbc.OracleDriver

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.OracleDialect

What do these properties do?

  • `spring.datasource.url` tells Spring Boot how to reach your Oracle database. The FREEPDB1 part targets the pluggable database where your user lives.
  • `spring.datasource.username` and password are the credentials for the tasktracker user you just created.
  • `spring.jpa.hibernate.ddl-auto=update` tells Hibernate to automatically create or update database tables based on your Java entity classes.
  • `spring.jpa.show-sql=true` prints every SQL query Hibernate runs to the console. This is helpful for learning and debugging.
  • `spring.jpa.properties.hibernate.dialect` tells Hibernate to generate Oracle-compatible SQL syntax.
  • Save application.properties by pressing Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Double check you have saved your application.properties file.

ⓧ I'd like to double check the full code

Here is the complete application.properties file:

spring.datasource.url=jdbc:oracle:thin:@localhost:1521/FREEPDB1
spring.datasource.username=tasktracker
spring.datasource.password=tasktracker123
spring.datasource.driver-class-name=oracle.jdbc.OracleDriver

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.OracleDialect

Not sure if Spring Boot can connect?

You will verify the connection in the next step when you run the application. For now, make sure your Oracle container is still running by checking docker ps in your terminal.

If you stopped the container, restart it with docker start oracle-free.

Help me troubleshoot my Spring Boot Oracle connection

Your database is running and Spring Boot knows how to reach it. Next up, you will design the database schema by creating JPA entity classes that Hibernate turns into Oracle tables automatically.

Design Your Database Schema and JPA Entities

Your task tracker needs to persist data about teams, engineers, and tasks. A well-designed database schema defines how these entities relate to each other and what constraints keep data consistent.

JPA (Java Persistence API) lets you define database tables using annotated Java classes. Hibernate reads those annotations at startup and auto-creates the corresponding tables in Oracle.

In this step, get ready to:
  • Create JPA entity classes for teams, engineers, and tasks.
  • Define foreign key relationships using @ManyToOne and @OneToMany annotations.
  • Verify Hibernate auto-creates the tables in Oracle Database.
Create the Team entity class

Every JPA entity maps to a database table. The Team class represents the teams table and holds a team's name, department, and the list of engineers that belong to it.

  • In IntelliJ's Project panel on the left, right-click the com.tasktracker package.
  • Select New then Package.
  • Type model and press Enter.
  • Right-click the new model package.
  • Select New then Java Class.
  • Type Team and press Enter.
  • Replace the generated file contents with this code:
package com.tasktracker.model;

import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "teams")
public class Team {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank
    @Column(nullable = false, unique = true)
    private String name;

    private String department;

    @OneToMany(mappedBy = "team", cascade = CascadeType.ALL)
    private List<Engineer> engineers = new ArrayList<>();

    public Team() {}

    public Team(String name, String department) {
        this.name = name;
        this.department = department;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getDepartment() { return department; }
    public void setDepartment(String department) { this.department = department; }
    public List<Engineer> getEngineers() { return engineers; }
    public void setEngineers(List<Engineer> engineers) { this.engineers = engineers; }
}

What does this code do?

This class defines the teams table in your Oracle Database.

  • @Entity tells JPA this class maps to a database table.
  • @Table(name = "teams") sets the exact table name in Oracle.
  • @Id and @GeneratedValue(strategy = GenerationType.IDENTITY) make the id column auto-increment.
  • @NotBlank adds validation so the name cannot be empty or null.
  • @OneToMany(mappedBy = "team") establishes a one-to-many relationship with engineers. You will create the Engineer class next.
  • Save Team.java.

Why does Team need an empty constructor?

JPA requires a no-argument constructor to create entity instances when loading data from the database. The second constructor with parameters is a convenience for your own code when creating new Team objects.

✔️ Awesome, I've got everything!

Great. Make sure you have saved Team.java before moving on.

ⓧ I'd like to double check the full code

package com.tasktracker.model;

import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "teams")
public class Team {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank
    @Column(nullable = false, unique = true)
    private String name;

    private String department;

    @OneToMany(mappedBy = "team", cascade = CascadeType.ALL)
    private List<Engineer> engineers = new ArrayList<>();

    public Team() {}

    public Team(String name, String department) {
        this.name = name;
        this.department = department;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getDepartment() { return department; }
    public void setDepartment(String department) { this.department = department; }
    public List<Engineer> getEngineers() { return engineers; }
    public void setEngineers(List<Engineer> engineers) { this.engineers = engineers; }
}
Define relationships with the Engineer and Task entities

An engineer belongs to one team, and a task belongs to both a team and an assignee. These "many-to-one" relationships map to foreign key columns in the database.

  • Right-click the model package.
  • Select New then Java Class.
  • Type Engineer and press Enter.
  • Replace the generated file contents with this code:
package com.tasktracker.model;

import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;

@Entity
@Table(name = "engineers")
public class Engineer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank
    @Column(nullable = false)
    private String name;

    @NotBlank
    @Column(nullable = false)
    private String email;

    @ManyToOne
    @JoinColumn(name = "team_id")
    @JsonIgnore
    private Team team;

    public Engineer() {}

    public Engineer(String name, String email, Team team) {
        this.name = name;
        this.email = email;
        this.team = team;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public Team getTeam() { return team; }
    public void setTeam(Team team) { this.team = team; }
}

What does this code do?

  • @ManyToOne tells Hibernate that many engineers can belong to one team.
  • @JoinColumn(name = "team_id") creates a team_id foreign key column in the engineers table.
  • @JsonIgnore prevents infinite recursion when serializing to JSON. Without it, loading an engineer would load its team, which would load all its engineers, and so on.
  • Save Engineer.java.

Seeing a red underline on Team?

If IntelliJ shows an error on the Team reference, make sure you saved Team.java in the same model package first. IntelliJ needs both files in the same package to resolve the import.

help me fix this red underline error on Team in my Engineer class

✔️ Awesome, I've got everything!

Great. Make sure you have saved Engineer.java before moving on.

ⓧ I'd like to double check the full code

package com.tasktracker.model;

import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;

@Entity
@Table(name = "engineers")
public class Engineer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank
    @Column(nullable = false)
    private String name;

    @NotBlank
    @Column(nullable = false)
    private String email;

    @ManyToOne
    @JoinColumn(name = "team_id")
    @JsonIgnore
    private Team team;

    public Engineer() {}

    public Engineer(String name, String email, Team team) {
        this.name = name;
        this.email = email;
        this.team = team;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public Team getTeam() { return team; }
    public void setTeam(Team team) { this.team = team; }
}

Now create the Task entity, which links to both a team and an individual engineer.

  • Right-click the model package.
  • Select New then Java Class.
  • Type Task and press Enter.
  • Replace the generated file contents with this code:
package com.tasktracker.model;

import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;

@Entity
@Table(name = "tasks")
public class Task {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank
    @Column(nullable = false)
    private String title;

    private String description;

    @Column(nullable = false)
    private String status = "TODO";

    @Column(nullable = false)
    private String priority = "MEDIUM";

    @ManyToOne
    @JoinColumn(name = "assignee_id")
    private Engineer assignee;

    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;

    public Task() {}

    public Task(String title, String description, String priority, Engineer assignee, Team team) {
        this.title = title;
        this.description = description;
        this.priority = priority;
        this.assignee = assignee;
        this.team = team;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
    public String getPriority() { return priority; }
    public void setPriority(String priority) { this.priority = priority; }
    public Engineer getAssignee() { return assignee; }
    public void setAssignee(Engineer assignee) { this.assignee = assignee; }
    public Team getTeam() { return team; }
    public void setTeam(Team team) { this.team = team; }
}

What does this code do?

The Task entity has two foreign key relationships.

  • assignee_id links each task to the engineer who owns it.
  • team_id links each task to the team responsible for it.
  • status defaults to "TODO" and priority defaults to "MEDIUM" so new tasks start with sensible values.
  • Save Task.java.

✔️ Awesome, I've got everything!

Great. Make sure you have saved Task.java before moving on.

ⓧ I'd like to double check the full code

package com.tasktracker.model;

import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;

@Entity
@Table(name = "tasks")
public class Task {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank
    @Column(nullable = false)
    private String title;

    private String description;

    @Column(nullable = false)
    private String status = "TODO";

    @Column(nullable = false)
    private String priority = "MEDIUM";

    @ManyToOne
    @JoinColumn(name = "assignee_id")
    private Engineer assignee;

    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;

    public Task() {}

    public Task(String title, String description, String priority, Engineer assignee, Team team) {
        this.title = title;
        this.description = description;
        this.priority = priority;
        this.assignee = assignee;
        this.team = team;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
    public String getPriority() { return priority; }
    public void setPriority(String priority) { this.priority = priority; }
    public Engineer getAssignee() { return assignee; }
    public void setAssignee(Engineer assignee) { this.assignee = assignee; }
    public Team getTeam() { return team; }
    public void setTeam(Team team) { this.team = team; }
}
Run the application to verify table creation

Hibernate's ddl-auto=update setting tells it to inspect your entity classes and create any missing tables automatically. Running the application now triggers that process against your Oracle Database.

  • Open TaskTrackerApiApplication.java in the editor.
  • Click the green play button (▶) in the gutter next to public static void main.
  • Select Run 'TaskTrackerApiApplication' from the popup menu.

What should I see?

In the Run panel at the bottom of IntelliJ, you should see log output ending with a line like Started TaskTrackerApiApplication.

Because show-sql=true is configured, you will also see Hibernate's SQL statements in the console. Look for create table statements for teams, engineers, and tasks.

  • Confirm the application started successfully and the Run panel shows no red error text.

Application fails to start?

If you see a connection error, make sure your Oracle Docker container is still running. Open a terminal and check with:

docker ps

You should see oracle-free listed. If it is not running, start it again with:

docker start oracle-free

help me debug why my Spring Boot app cannot connect to Oracle Database

Your database schema is live in Oracle. Next up, you will build the repository and service layers that read and write data to these tables.

Build the Repository and Service Layers

Your entity classes tell Hibernate what your tables look like. Now you need a way to actually read and write data to those tables from Java code.

In this step, you will create repository interfaces that handle database operations and service classes that hold your business logic. This is the Controller-Service-Repository pattern that enterprise Java teams use to keep code organized and testable.

In this step, get ready to:
  • Create Spring Data JPA repository interfaces for each entity.
  • Build service classes with business logic for CRUD operations.
  • Add custom query methods to find tasks by team and status.
Create Spring Data JPA repository interfaces for each entity

A repository is the layer that talks directly to the database. With Spring Data JPA, you define an interface and Spring auto-generates the implementation at runtime. You get full CRUD operations without writing a single SQL query.

  • In IntelliJ, right-click the com.tasktracker package in the src/main/java folder.
  • Select New, then Package.
  • Name the package repository and press Enter.

Now create the first repository interface.

  • Right-click the repository package and select New then Java Class.
  • Select Interface from the kind dropdown.
  • Name it TeamRepository and press Enter.
  • Replace the file contents with the following code:
package com.tasktracker.repository;

import com.tasktracker.model.Team;
import org.springframework.data.jpa.repository.JpaRepository;

public interface TeamRepository extends JpaRepository<Team, Long> {
}

What does this code do?

By extending JpaRepository<Team, Long>, Spring Data JPA auto-generates methods like findAll(), findById(), save(), and deleteById() at runtime.

The two type parameters are the entity class (Team) and the type of its primary key (Long). You write zero implementation code.

  • Create another interface in the repository package named EngineerRepository with this code:
package com.tasktracker.repository;

import com.tasktracker.model.Engineer;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface EngineerRepository extends JpaRepository<Engineer, Long> {
    List<Engineer> findByTeamId(Long teamId);
}

What is findByTeamId?

Spring Data JPA reads method names and generates SQL automatically. The method name findByTeamId translates to SELECT * FROM engineers WHERE team_id = ? behind the scenes.

You define the method signature. Spring writes the query.

  • Create one more interface in the repository package named TaskRepository with this code:
package com.tasktracker.repository;

import com.tasktracker.model.Task;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface TaskRepository extends JpaRepository<Task, Long> {
    List<Task> findByTeamId(Long teamId);
    List<Task> findByTeamIdAndStatus(Long teamId, String status);
    List<Task> findByAssigneeId(Long assigneeId);
}

What do these custom methods do?

  • findByTeamId returns all tasks belonging to a specific team.
  • findByTeamIdAndStatus filters tasks by both team AND status (like finding all TODO tasks for one team).
  • findByAssigneeId returns all tasks assigned to a specific engineer.
  • Save all three files.

Seeing red underlines on the imports?

Make sure you selected Interface (not Class) when creating the file. If you accidentally created a class, delete the file and recreate it as an interface.

Also verify the package name matches com.tasktracker.repository exactly. A typo in the package means IntelliJ cannot resolve the model imports.

help me fix import errors in my repository interfaces

Build service classes with business logic for CRUD operations

Repositories handle raw database access. Service classes sit on top and hold your business logic. This separation means you can change how data is stored without rewriting your business rules.

  • Right-click the com.tasktracker package, select New then Package, and name it service.
  • Create a new Java class in the service package named TeamService with the following code:
package com.tasktracker.service;

import com.tasktracker.model.Team;
import com.tasktracker.repository.TeamRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;

@Service
public class TeamService {

    private final TeamRepository teamRepository;

    // Constructor injection - Spring automatically passes the repository
    public TeamService(TeamRepository teamRepository) {
        this.teamRepository = teamRepository;
    }

    // Return all teams from the database
    public List<Team> getAllTeams() {
        return teamRepository.findAll();
    }

    // Find a single team by its ID
    public Optional<Team> getTeamById(Long id) {
        return teamRepository.findById(id);
    }

    // Save a new team to the database
    public Team createTeam(Team team) {
        return teamRepository.save(team);
    }

    // Update an existing team's fields
    public Optional<Team> updateTeam(Long id, Team updatedTeam) {
        return teamRepository.findById(id).map(team -> {
            team.setName(updatedTeam.getName());
            team.setDepartment(updatedTeam.getDepartment());
            return teamRepository.save(team);
        });
    }

    // Remove a team by its ID
    public void deleteTeam(Long id) {
        teamRepository.deleteById(id);
    }
}

What does this code do?

  • @Service tells Spring to manage this class as a bean and inject it where needed.
  • Constructor injection receives the TeamRepository automatically. This is the recommended pattern over field injection.
  • updateTeam uses Optional.map() to safely update only if the team exists. If the ID is not found, it returns an empty Optional.
  • Save TeamService.java.

Now create the service for tasks.

  • Create a new Java class in the service package named TaskService with the following code:
package com.tasktracker.service;

import com.tasktracker.model.Task;
import com.tasktracker.repository.TaskRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;

@Service
public class TaskService {

    private final TaskRepository taskRepository;

    // Constructor injection for the task repository
    public TaskService(TaskRepository taskRepository) {
        this.taskRepository = taskRepository;
    }

    // Return all tasks from the database
    public List<Task> getAllTasks() {
        return taskRepository.findAll();
    }

    // Find a single task by its ID
    public Optional<Task> getTaskById(Long id) {
        return taskRepository.findById(id);
    }

    // Save a new task to the database
    public Task createTask(Task task) {
        return taskRepository.save(task);
    }

    // Update all fields of an existing task
    public Optional<Task> updateTask(Long id, Task updatedTask) {
        return taskRepository.findById(id).map(task -> {
            task.setTitle(updatedTask.getTitle());
            task.setDescription(updatedTask.getDescription());
            task.setStatus(updatedTask.getStatus());
            task.setPriority(updatedTask.getPriority());
            task.setAssignee(updatedTask.getAssignee());
            task.setTeam(updatedTask.getTeam());
            return taskRepository.save(task);
        });
    }

    // Remove a task by its ID
    public void deleteTask(Long id) {
        taskRepository.deleteById(id);
    }

    // Get all tasks belonging to a specific team
    public List<Task> getTasksByTeam(Long teamId) {
        return taskRepository.findByTeamId(teamId);
    }

    // Get tasks filtered by team and status
    public List<Task> getTasksByTeamAndStatus(Long teamId, String status) {
        return taskRepository.findByTeamIdAndStatus(teamId, status);
    }
}

What does this code do?

TaskService follows the same pattern as TeamService. The updateTask method copies all fields from the incoming task to the existing one before saving.

The last two methods (getTasksByTeam and getTasksByTeamAndStatus) delegate to the custom query methods you defined in TaskRepository. The repository generates the SQL. The service exposes the results.

  • Save TaskService.java.

Cannot resolve symbol TeamRepository or TaskRepository?

Check that the import statements at the top of each service file point to com.tasktracker.repository.TeamRepository and com.tasktracker.repository.TaskRepository. IntelliJ should auto-suggest the import if you press Alt+Enter on the red text.

help me fix unresolved repository imports in my service classes

Verify the application compiles with the new layers

With both the repository and service layers in place, confirm that Spring Boot can wire everything together.

  • Run the application by clicking the green play button next to the main method in TaskTrackerApiApplication.java.

You should see the Spring Boot banner followed by a line like Started TaskTrackerApiApplication in your console. No errors about missing beans or unresolved dependencies.

Seeing a BeanCreationException?

This usually means Spring cannot find one of your repository interfaces. Verify that all repository interfaces are inside the com.tasktracker.repository package. Spring component scanning only finds classes and interfaces under the base package (com.tasktracker) and its sub-packages.

help me debug a BeanCreationException in my Spring Boot app

✔️ Awesome, I've got everything!

Great. Double check you have saved all files before moving on.

ⓧ I'd like to double check the full code

Here is the full code for each new file in this step.

package com.tasktracker.repository;

import com.tasktracker.model.Team;
import org.springframework.data.jpa.repository.JpaRepository;

public interface TeamRepository extends JpaRepository<Team, Long> {
}
package com.tasktracker.repository;

import com.tasktracker.model.Engineer;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface EngineerRepository extends JpaRepository<Engineer, Long> {
    List<Engineer> findByTeamId(Long teamId);
}
package com.tasktracker.repository;

import com.tasktracker.model.Task;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface TaskRepository extends JpaRepository<Task, Long> {
    List<Task> findByTeamId(Long teamId);
    List<Task> findByTeamIdAndStatus(Long teamId, String status);
    List<Task> findByAssigneeId(Long assigneeId);
}
package com.tasktracker.service;

import com.tasktracker.model.Team;
import com.tasktracker.repository.TeamRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;

@Service
public class TeamService {

    private final TeamRepository teamRepository;

    public TeamService(TeamRepository teamRepository) {
        this.teamRepository = teamRepository;
    }

    public List<Team> getAllTeams() {
        return teamRepository.findAll();
    }

    public Optional<Team> getTeamById(Long id) {
        return teamRepository.findById(id);
    }

    public Team createTeam(Team team) {
        return teamRepository.save(team);
    }

    public Optional<Team> updateTeam(Long id, Team updatedTeam) {
        return teamRepository.findById(id).map(team -> {
            team.setName(updatedTeam.getName());
            team.setDepartment(updatedTeam.getDepartment());
            return teamRepository.save(team);
        });
    }

    public void deleteTeam(Long id) {
        teamRepository.deleteById(id);
    }
}
package com.tasktracker.service;

import com.tasktracker.model.Task;
import com.tasktracker.repository.TaskRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;

@Service
public class TaskService {

    private final TaskRepository taskRepository;

    public TaskService(TaskRepository taskRepository) {
        this.taskRepository = taskRepository;
    }

    public List<Task> getAllTasks() {
        return taskRepository.findAll();
    }

    public Optional<Task> getTaskById(Long id) {
        return taskRepository.findById(id);
    }

    public Task createTask(Task task) {
        return taskRepository.save(task);
    }

    public Optional<Task> updateTask(Long id, Task updatedTask) {
        return taskRepository.findById(id).map(task -> {
            task.setTitle(updatedTask.getTitle());
            task.setDescription(updatedTask.getDescription());
            task.setStatus(updatedTask.getStatus());
            task.setPriority(updatedTask.getPriority());
            task.setAssignee(updatedTask.getAssignee());
            task.setTeam(updatedTask.getTeam());
            return taskRepository.save(task);
        });
    }

    public void deleteTask(Long id) {
        taskRepository.deleteById(id);
    }

    public List<Task> getTasksByTeam(Long teamId) {
        return taskRepository.findByTeamId(teamId);
    }

    public List<Task> getTasksByTeamAndStatus(Long teamId, String status) {
        return taskRepository.findByTeamIdAndStatus(teamId, status);
    }
}

Your data access and business logic layers are complete. Next up, you will expose these services as REST API endpoints that clients can call over HTTP.

Create REST API Controllers

Your service layer handles all the business logic for managing teams and tasks. Now you need a way for the outside world to actually talk to that logic over HTTP.

REST controllers are the front door of your API. They receive incoming HTTP requests, delegate the real work to services, and return properly formatted JSON responses with the correct status codes.

In this step, get ready to:
  • Create a TeamController with full CRUD endpoints.
  • Create a TaskController with CRUD plus custom query endpoints.
  • Test all endpoints using curl or Postman.
Create the TeamController

The controller layer maps HTTP methods to your service methods. Each endpoint corresponds to a standard REST operation: GET to read, POST to create, PUT to update, DELETE to remove.

  • In IntelliJ IDEA, right-click the com.tasktracker package in the src/main/java folder.
  • Select New then Package.
  • Name the package controller and press Enter.
  • Right-click the new controller package, select New then Java Class, and name it TeamController.
  • Replace the file contents with the following code:
package com.tasktracker.controller;

import com.tasktracker.model.Team;
import com.tasktracker.service.TeamService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;

// Marks this class as a REST controller that returns JSON automatically
@RestController
@RequestMapping("/api/teams")
public class TeamController {

    private final TeamService teamService;

    // Spring injects the TeamService through the constructor
    public TeamController(TeamService teamService) {
        this.teamService = teamService;
    }

    // GET /api/teams - retrieve all teams
    @GetMapping
    public List<Team> getAllTeams() {
        return teamService.getAllTeams();
    }

    // GET /api/teams/{id} - retrieve a single team by ID
    @GetMapping("/{id}")
    public ResponseEntity<Team> getTeamById(@PathVariable Long id) {
        return teamService.getTeamById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    // POST /api/teams - create a new team, returns 201 Created
    @PostMapping
    public ResponseEntity<Team> createTeam(@Valid @RequestBody Team team) {
        Team created = teamService.createTeam(team);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }

    // PUT /api/teams/{id} - update an existing team
    @PutMapping("/{id}")
    public ResponseEntity<Team> updateTeam(@PathVariable Long id, @Valid @RequestBody Team team) {
        return teamService.updateTeam(id, team)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    // DELETE /api/teams/{id} - remove a team, returns 204 No Content
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteTeam(@PathVariable Long id) {
        teamService.deleteTeam(id);
        return ResponseEntity.noContent().build();
    }
}

What does this code do?

This controller exposes five endpoints, all under the /api/teams base path.

  • @RestController tells Spring Boot to automatically serialize return values to JSON.
  • @RequestMapping("/api/teams") sets the base URL path for every endpoint in this class.
  • ResponseEntity gives you fine-grained control over the HTTP status code returned (201 for created, 204 for deleted, 404 for not found).
  • @Valid triggers the validation annotations (like @NotBlank) on the incoming request body before it reaches your service.
  • Save TeamController.java.
  • Check that IntelliJ shows no red underlines in the file. The class should compile without errors.

Seeing red underlines on the imports?

Make sure the file is inside the com.tasktracker.controller package. The package declaration on line 1 must say package com.tasktracker.controller; exactly.

If IntelliJ cannot resolve TeamService, confirm that your service class exists at src/main/java/com/tasktracker/service/TeamService.java.

Help me fix import errors in my TeamController.

Create the TaskController

The TaskController follows the same pattern as TeamController but adds two extra endpoints for querying tasks by team and by status. These custom endpoints map directly to the query methods you built in the service layer.

  • Right-click the controller package, select New then Java Class, and name it TaskController.
  • Replace the file contents with the following code:
package com.tasktracker.controller;

import com.tasktracker.model.Task;
import com.tasktracker.service.TaskService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;

// REST controller for task management endpoints
@RestController
@RequestMapping("/api/tasks")
public class TaskController {

    private final TaskService taskService;

    public TaskController(TaskService taskService) {
        this.taskService = taskService;
    }

    // GET /api/tasks - retrieve all tasks
    @GetMapping
    public List<Task> getAllTasks() {
        return taskService.getAllTasks();
    }

    // GET /api/tasks/{id} - retrieve a single task
    @GetMapping("/{id}")
    public ResponseEntity<Task> getTaskById(@PathVariable Long id) {
        return taskService.getTaskById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    // POST /api/tasks - create a new task, returns 201 Created
    @PostMapping
    public ResponseEntity<Task> createTask(@Valid @RequestBody Task task) {
        Task created = taskService.createTask(task);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }

    // PUT /api/tasks/{id} - update an existing task
    @PutMapping("/{id}")
    public ResponseEntity<Task> updateTask(@PathVariable Long id, @Valid @RequestBody Task task) {
        return taskService.updateTask(id, task)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    // DELETE /api/tasks/{id} - remove a task, returns 204 No Content
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteTask(@PathVariable Long id) {
        taskService.deleteTask(id);
        return ResponseEntity.noContent().build();
    }

    // GET /api/tasks/team/{teamId} - filter tasks by team
    @GetMapping("/team/{teamId}")
    public List<Task> getTasksByTeam(@PathVariable Long teamId) {
        return taskService.getTasksByTeam(teamId);
    }

    // GET /api/tasks/team/{teamId}/status/{status} - filter by team AND status
    @GetMapping("/team/{teamId}/status/{status}")
    public List<Task> getTasksByTeamAndStatus(@PathVariable Long teamId, @PathVariable String status) {
        return taskService.getTasksByTeamAndStatus(teamId, status);
    }
}

What does this code do?

TaskController has the same five CRUD endpoints as TeamController, plus two custom query endpoints.

  • GET /api/tasks/team/{teamId} returns all tasks belonging to a specific team. This is useful for team dashboards.
  • GET /api/tasks/team/{teamId}/status/{status} narrows the filter further, letting you request only TODO or IN_PROGRESS tasks for a given team.
  • @PathVariable extracts values from the URL path segments and passes them as method parameters.
  • Save TaskController.java.
  • Confirm IntelliJ shows no red underlines in the file.

Cannot resolve TaskService?

Double-check that your TaskService.java file is in the com.tasktracker.service package. IntelliJ sometimes needs a moment to index new files. Try clicking Build then Rebuild Project to force a refresh.

Help me resolve missing class errors in TaskController.

✔️ Awesome, I've got everything!

Great. Make sure both controller files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the full TeamController.java:

package com.tasktracker.controller;

import com.tasktracker.model.Team;
import com.tasktracker.service.TeamService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
@RequestMapping("/api/teams")
public class TeamController {

    private final TeamService teamService;

    public TeamController(TeamService teamService) {
        this.teamService = teamService;
    }

    @GetMapping
    public List<Team> getAllTeams() {
        return teamService.getAllTeams();
    }

    @GetMapping("/{id}")
    public ResponseEntity<Team> getTeamById(@PathVariable Long id) {
        return teamService.getTeamById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    public ResponseEntity<Team> createTeam(@Valid @RequestBody Team team) {
        Team created = teamService.createTeam(team);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }

    @PutMapping("/{id}")
    public ResponseEntity<Team> updateTeam(@PathVariable Long id, @Valid @RequestBody Team team) {
        return teamService.updateTeam(id, team)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteTeam(@PathVariable Long id) {
        teamService.deleteTeam(id);
        return ResponseEntity.noContent().build();
    }
}

Here is the full TaskController.java:

package com.tasktracker.controller;

import com.tasktracker.model.Task;
import com.tasktracker.service.TaskService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
@RequestMapping("/api/tasks")
public class TaskController {

    private final TaskService taskService;

    public TaskController(TaskService taskService) {
        this.taskService = taskService;
    }

    @GetMapping
    public List<Task> getAllTasks() {
        return taskService.getAllTasks();
    }

    @GetMapping("/{id}")
    public ResponseEntity<Task> getTaskById(@PathVariable Long id) {
        return taskService.getTaskById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    public ResponseEntity<Task> createTask(@Valid @RequestBody Task task) {
        Task created = taskService.createTask(task);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }

    @PutMapping("/{id}")
    public ResponseEntity<Task> updateTask(@PathVariable Long id, @Valid @RequestBody Task task) {
        return taskService.updateTask(id, task)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteTask(@PathVariable Long id) {
        taskService.deleteTask(id);
        return ResponseEntity.noContent().build();
    }

    @GetMapping("/team/{teamId}")
    public List<Task> getTasksByTeam(@PathVariable Long teamId) {
        return taskService.getTasksByTeam(teamId);
    }

    @GetMapping("/team/{teamId}/status/{status}")
    public List<Task> getTasksByTeamAndStatus(@PathVariable Long teamId, @PathVariable String status) {
        return taskService.getTasksByTeamAndStatus(teamId, status);
    }
}
Test all endpoints using Postman or curl

Your API is wired up but untested. You need to start the application and send real HTTP requests to confirm everything works end-to-end with your Oracle database.

  • Run the application by clicking the green play button next to the main method in TaskTrackerApiApplication.java, or press Ctrl+Shift+F10 (Windows) or Ctrl+Shift+R (macOS).
  • Wait for the console to show Started TaskTrackerApiApplication which confirms the server is running on port 8080.

What is Postman?

Postman is a desktop app for sending HTTP requests to APIs. It provides a visual interface for setting request methods, headers, and body content. You can also use curl from your terminal if you prefer a command-line approach.

  • Open a terminal window (separate from IntelliJ's run console) and create a new team by running:
curl -X POST http://localhost:8080/api/teams -H "Content-Type: application/json" -d "{\"name\": \"Platform Team\", \"department\": \"Engineering\"}"
  • You should see a JSON response containing the team with an id field and HTTP status 201.
  • Retrieve all teams by running:
curl http://localhost:8080/api/teams
  • You should see a JSON array containing the team you just created.
  • Create a task assigned to that team by running:
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d "{\"title\": \"Implement login API\", \"description\": \"Build OAuth2 login endpoint\", \"priority\": \"HIGH\", \"team\": {\"id\": 1}}"
  • You should see the task returned with an id, a default status of TODO, and the team reference.
  • Test the custom query endpoint by filtering tasks for team 1:
curl http://localhost:8080/api/tasks/team/1
  • You should see an array containing only the task(s) assigned to team 1.

Testing with Postman instead

If you prefer a visual tool, open Postman and create a new request. Set the method to POST, the URL to http://localhost:8080/api/teams, and the body type to raw JSON. Paste the same JSON payload from the curl commands above.

Getting connection refused?

Make sure your Spring Boot application is still running in IntelliJ. Check the console output for errors. If you see a database connection error, confirm that your Docker container oracle-free is still running.

Run docker ps to verify the container is up. If it stopped, restart it with docker start oracle-free.

Help me debug my REST API connection issues.

Your REST API is live and responding to requests. Next up, you will write unit tests to lock in the correctness of your service layer, then push everything to GitHub.

Add Unit Tests and Push to GitHub

Your API is working and you have verified every endpoint manually. Manual testing proves things work right now, but it does not protect against future changes breaking your code.

In this step, you will write automated unit tests using JUnit 5 and Mockito. Then you will push everything to GitHub with a professional README so recruiters and hiring managers can see your work.

In this step, get ready to:
  • Write JUnit 5 unit tests for the TaskService.
  • Initialize a Git repository and commit your code.
  • Push to GitHub with a professional README.
Write JUnit 5 tests for the service layer

Unit tests verify individual pieces of your code in isolation. By mocking the repository layer, you can test your service logic without needing the Oracle database running.

  • In IntelliJ, create a new package inside src/test/java called com.tasktracker.service.
  • Create a new Java class called TaskServiceTest inside that package.
  • Paste the following test code into TaskServiceTest.java:
package com.tasktracker.service;

import com.tasktracker.model.Task;
import com.tasktracker.model.Team;
import com.tasktracker.repository.TaskRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Arrays;
import java.util.List;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class TaskServiceTest {

    @Mock
    private TaskRepository taskRepository;

    @InjectMocks
    private TaskService taskService;

    private Task sampleTask;
    private Team sampleTeam;

    @BeforeEach
    void setUp() {
        sampleTeam = new Team("Platform Team", "Engineering");
        sampleTeam.setId(1L);
        sampleTask = new Task("Implement login API", "Build OAuth2 login endpoint", "HIGH", null, sampleTeam);
        sampleTask.setId(1L);
    }

    @Test
    void getAllTasks_returnsAllTasks() {
        when(taskRepository.findAll()).thenReturn(Arrays.asList(sampleTask));
        List<Task> tasks = taskService.getAllTasks();
        assertEquals(1, tasks.size());
        assertEquals("Implement login API", tasks.get(0).getTitle());
    }

    @Test
    void getTaskById_existingId_returnsTask() {
        when(taskRepository.findById(1L)).thenReturn(Optional.of(sampleTask));
        Optional<Task> result = taskService.getTaskById(1L);
        assertTrue(result.isPresent());
        assertEquals("Implement login API", result.get().getTitle());
    }

    @Test
    void getTaskById_nonExistingId_returnsEmpty() {
        when(taskRepository.findById(99L)).thenReturn(Optional.empty());
        Optional<Task> result = taskService.getTaskById(99L);
        assertFalse(result.isPresent());
    }

    @Test
    void createTask_savesAndReturnsTask() {
        when(taskRepository.save(sampleTask)).thenReturn(sampleTask);
        Task created = taskService.createTask(sampleTask);
        assertEquals("Implement login API", created.getTitle());
        verify(taskRepository, times(1)).save(sampleTask);
    }

    @Test
    void getTasksByTeam_returnsFilteredTasks() {
        when(taskRepository.findByTeamId(1L)).thenReturn(Arrays.asList(sampleTask));
        List<Task> tasks = taskService.getTasksByTeam(1L);
        assertEquals(1, tasks.size());
    }
}

What does this test code do?

This test class uses Mockito to create a fake TaskRepository so your tests never touch the real database.

  • @ExtendWith(MockitoExtension.class) tells JUnit to activate Mockito's annotation processing.
  • @Mock creates a fake repository that returns whatever you tell it to.
  • @InjectMocks creates a real TaskService but injects the fake repository into it.
  • @BeforeEach setUp() creates sample data that each test method can use.
  • Each @Test method verifies one specific behavior: fetching all tasks, finding by ID, handling missing IDs, saving, and filtering by team.
  • Save TaskServiceTest.java.
  • Open the IntelliJ terminal by pressing Alt+F12 (Windows) or clicking Terminal at the bottom of IntelliJ.
  • Run the tests by running this command:
mvn test
  • You should see output ending with BUILD SUCCESS and Tests run: 5, Failures: 0, Errors: 0.

Tests failing or not compiling?

  • Make sure your package declaration at the top of the test file is com.tasktracker.service and the file is in src/test/java/com/tasktracker/service/.
  • Verify that spring-boot-starter-test is listed in your pom.xml dependencies. This starter includes both JUnit 5 and Mockito.
  • If you see import errors, try clicking File then Invalidate Caches / Restart in IntelliJ.

Why aren't my JUnit tests passing in my Spring Boot project?

✔️ Awesome, I've got everything!

Great. Double check that you have saved your file before continuing.

ⓧ I'd like to double check the full code

package com.tasktracker.service;

import com.tasktracker.model.Task;
import com.tasktracker.model.Team;
import com.tasktracker.repository.TaskRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Arrays;
import java.util.List;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class TaskServiceTest {

    @Mock
    private TaskRepository taskRepository;

    @InjectMocks
    private TaskService taskService;

    private Task sampleTask;
    private Team sampleTeam;

    @BeforeEach
    void setUp() {
        sampleTeam = new Team("Platform Team", "Engineering");
        sampleTeam.setId(1L);
        sampleTask = new Task("Implement login API", "Build OAuth2 login endpoint", "HIGH", null, sampleTeam);
        sampleTask.setId(1L);
    }

    @Test
    void getAllTasks_returnsAllTasks() {
        when(taskRepository.findAll()).thenReturn(Arrays.asList(sampleTask));
        List<Task> tasks = taskService.getAllTasks();
        assertEquals(1, tasks.size());
        assertEquals("Implement login API", tasks.get(0).getTitle());
    }

    @Test
    void getTaskById_existingId_returnsTask() {
        when(taskRepository.findById(1L)).thenReturn(Optional.of(sampleTask));
        Optional<Task> result = taskService.getTaskById(1L);
        assertTrue(result.isPresent());
        assertEquals("Implement login API", result.get().getTitle());
    }

    @Test
    void getTaskById_nonExistingId_returnsEmpty() {
        when(taskRepository.findById(99L)).thenReturn(Optional.empty());
        Optional<Task> result = taskService.getTaskById(99L);
        assertFalse(result.isPresent());
    }

    @Test
    void createTask_savesAndReturnsTask() {
        when(taskRepository.save(sampleTask)).thenReturn(sampleTask);
        Task created = taskService.createTask(sampleTask);
        assertEquals("Implement login API", created.getTitle());
        verify(taskRepository, times(1)).save(sampleTask);
    }

    @Test
    void getTasksByTeam_returnsFilteredTasks() {
        when(taskRepository.findByTeamId(1L)).thenReturn(Arrays.asList(sampleTask));
        List<Task> tasks = taskService.getTasksByTeam(1L);
        assertEquals(1, tasks.size());
    }
}
Initialize a Git repository and commit with meaningful messages

Professional projects track every change with Git. A clean commit history shows reviewers how you built the project incrementally.

  • First, create a .gitignore file in your project root (the task-tracker-api folder) to keep generated files out of version control.
  • In IntelliJ, right-click the project root in the Project panel, select New, then File.
  • Name the file .gitignore and paste this content:
target/
.idea/
*.iml
.mvn/
mvnw
mvnw.cmd
*.class
*.jar

What does .gitignore do?

The .gitignore file tells Git which files to skip. The target/ folder contains compiled output, and .idea/ holds IntelliJ settings. Neither belongs in your repository because they are machine-generated and differ per developer.

  • Save .gitignore.
  • Initialize your Git repository and make your first commit by running these commands in the IntelliJ terminal:
git init
git add .
git commit -m "Initial commit: Spring Boot REST API with Oracle Database"
  • You should see a summary showing files committed, like 15 files changed (or similar depending on your project structure).

Seeing 'git' is not recognized?

Git must be installed on your system. If you do not have it, download it from git-scm.com and restart IntelliJ after installing.

Help me install Git on my machine so I can push my project to GitHub.

Push to GitHub with a professional README

A README is the first thing a recruiter or engineer sees when they open your repository. It should clearly explain what the project does and how to run it.

  • Create a new file called README.md in your project root.
  • Add the following content to your README:
# Cross-Team Task Tracker REST API

A Spring Boot REST API that manages engineers, teams, and tasks across an organization.
Built with Oracle Database for persistence and JPA/Hibernate for ORM.

## Tech Stack

- Java 21
- Spring Boot 3.5
- Spring Data JPA / Hibernate
- Oracle Database (Docker)
- JUnit 5 + Mockito
- Maven

## How to Run

1. Start Oracle Database: `docker start oracle-free`
2. Build the project: `mvn clean install`
3. Run the application: `mvn spring-boot:run`
4. Access the API at `http://localhost:8080/api/teams`

## API Endpoints

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | /api/teams | List all teams |
| POST | /api/teams | Create a team |
| GET | /api/tasks | List all tasks |
| POST | /api/tasks | Create a task |
| GET | /api/tasks/team/{teamId} | Tasks by team |

## Running Tests

```bash
mvn test
```

Why does a README matter?

Hiring managers often spend less than 30 seconds on a repository. A clear README with a tech stack, setup instructions, and endpoint table immediately communicates that you write professional, maintainable code.

  • Save README.md.
  • Open GitHub in your browser and create a new repository.
  • Click the + icon in the top right corner, then select New repository.
  • Name your repository task-tracker-api.
  • Leave it set to Public.
  • Do NOT check any boxes for README, .gitignore, or license (you already have these locally).
  • Click Create repository.

Why leave the repository empty?

GitHub will show you quick setup instructions. Since you already initialized Git locally, you need an empty remote to push to. Adding a README on GitHub would create a merge conflict with your local one.

  • GitHub will show a page with setup commands. Copy the HTTPS URL for your repository (it looks like https://github.com/YOUR_USERNAME/task-tracker-api.git).
  • Back in the IntelliJ terminal, commit your README and push everything by running these commands:
git add .
git commit -m "Add README and .gitignore"
git branch -M main
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/task-tracker-api.git"]]
git push -u origin main

What do these commands do?

  • git branch -M main renames your default branch to main (the GitHub standard).
  • git remote add origin connects your local repository to the GitHub remote.
  • git push -u origin main uploads all your commits to GitHub.
  • Git may prompt you to sign in to GitHub. Follow the browser authentication flow if it appears.
  • Refresh your GitHub repository page. You should see all your project files, your README rendered at the bottom, and a commit history showing your two commits.

Push rejected or authentication failed?

  • If you see a permission error, make sure you are signed in to the correct GitHub account in your browser.
  • If the push is rejected, double check that you did NOT add a README or .gitignore on GitHub when creating the repository.
  • If you accidentally initialized the GitHub repo with files, delete the repository on GitHub and create it again (empty this time).

Help me push my Spring Boot project to GitHub from the IntelliJ terminal.

Your project is now live on GitHub with automated tests and a professional README. Next up, tackle the Secret Mission to add sprint analytics.

Secret mission

Sprint Analytics Endpoint

Your task tracker works. But in a real engineering org, managers need visibility into how teams are performing. Can you build an analytics endpoint that aggregates task completion metrics across every team in a single API call?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs locally using Docker, so there are no ongoing monetary costs. The Oracle Database container uses approximately 2-4 GB of RAM while running.

Resources you used:

  • Docker container: oracle-free (running Oracle Database Free).
  • Docker image: container-registry.oracle.com/database/free:latest-lite.
  • Local project files (the task-tracker-api folder on your Desktop).
  • GitHub repository.

Keep everything running

No action needed. Choose this if you're still actively building or plan to add the Secret Mission.

  • Your Docker container continues running in the background, using 2-4 GB of RAM.
  • Your Spring Boot project files remain on your Desktop.
  • Your GitHub repository stays public (or private, depending on your choice).

Pause - I'll come back to this later

Shut down the Docker container to free up RAM. Your data persists inside the stopped container, so you can pick up right where you left off.

  • Stop the Oracle Database container by running:
docker stop oracle-free
  • When you're ready to resume, start it again by running:
docker start oracle-free
  • Your project files and GitHub repository remain untouched.

Delete - I don't want to use this again

Remove all project resources completely. This cannot be undone.

  • Remove the Docker container and image by running:
docker rm -f oracle-free
docker rmi container-registry.oracle.com/database/free:latest-lite
  • Delete your local project folder by running:

macOS

rm -rf ~/Desktop/task-tracker-api

Windows

rmdir /s /q %USERPROFILE%\Desktop\task-tracker-api
  • Delete your GitHub repository:
  • Open your repository on GitHub.
  • Click Settings (the tab below your repository name).
  • Scroll down to the Danger Zone section.
  • Click Delete this repository and follow the confirmation prompts.

Nice Work!

Nice Work!

Nice work! You just built a production-style REST API backed by Oracle's own database.

You've learned how to:

  • Built a Spring Boot REST API with Controller-Service-Repository architecture.
  • Designed a relational schema with foreign keys in Oracle Database running inside a Docker container.
  • Wrote JUnit 5 unit tests for business logic and pushed professional code to GitHub with a clean commit history.
  • Secret Mission: Built a Sprint Analytics endpoint with aggregate SQL queries.

Ready to quiz yourself?