Build a REST API with Spring Boot
Build an appointment booking API with business logic validation in Java.
Introduction
30 Second Summary
Think about booking a haircut, a dental appointment, or a car service. Behind every booking system is a backend API that enforces rules like "no double-booking" and "only during business hours."
In this project, you will build that backend API from scratch using Spring Boot and PostgreSQL. You will design data models, write business logic validations, and test your endpoints with Postman.
What You'll Build
A REST API for a service business that handles appointment booking, enforces real business rules, and connects to a PostgreSQL database.
By the end of this project, you'll have:
- A Spring Boot application with REST endpoints for managing services and appointments.
- Two JPA entities mapped to PostgreSQL tables with proper relationships.
- A service layer with business logic that prevents double-booking, rejects out-of-hours requests, and blocks past-date appointments.
- Secret Mission: Build an endpoint that calculates and returns available time slots for a given date and service.
Are there any prerequisites?
You should have basic Java knowledge (classes, interfaces, annotations) and basic SQL experience. If you have completed the previous PostgreSQL project in this series, you are ready to go.
Before We Start
You are about to build the backend API for an appointment booking system. This is the type of system real businesses use every day to manage their schedules.
Before you write any code, you need three tools configured on your machine: JDK 21 (the Java runtime and compiler), Spring Tools for Eclipse (your development environment), and Postman (for testing your API endpoints).
In this step, get ready to:
- Install JDK 21 and configure it for your terminal.
- Set up Spring Tools for Eclipse and generate your Spring Boot project.
- Install Postman and configure your PostgreSQL database.
Install JDK 21 and configure it for your terminal
Spring Boot 3.5 requires Java 17 or higher. This project uses JDK 21, the current Long-Term Support release from Oracle.
- Open a terminal window. Press Cmd+Space (macOS) or the Windows key (Windows) and type Terminal (macOS) or Command Prompt (Windows), then press Enter.
- Check whether Java is already installed by running:
java -version
✔️ I see version 21 or higher
You already have JDK 21 configured. Skip ahead to the next substep.
ⓧ I see an older version
Your system has an older Java version. You need to install JDK 21 and update your environment variables so the new version takes priority.
- Open https://www.oracle.com/java/technologies/downloads/ in your browser.
- Select the JDK 21 tab, then download the installer for your operating system (jdk-21_windows-x64_bin.exe for Windows, or the macOS installer for Mac).
- Run the installer and follow the on-screen prompts to complete installation.
After the installer finishes, configure the JAVA_HOME and PATH environment variables so your terminal uses JDK 21.
On Windows:
- Press the Windows key and search for Environment Variables.
- Click Edit the system environment variables.
- Click Environment Variables.
- Under System variables, click New and add JAVA_HOME with the value:
- C:\Program Files\Java\jdk-21
- Find Path in the list, click Edit, and add a new entry:
- %JAVA_HOME%\bin
- Click OK on all dialogs to save.
On macOS:
- Open Terminal and add the following to your shell profile (~/.zshrc or ~/.bash_profile):
export JAVA_HOME=$(/usr/libexec/java_home -v 21)
export PATH=$JAVA_HOME/bin:$PATH
- Save the file and run source ~/.zshrc to apply changes.
Open a new terminal window and verify JDK 21 is active by running:
java -version
You should see output containing java version "21 or similar.
ⓧ Command not found
Java is not installed on your machine. Follow these steps to install JDK 21 from Oracle.
- Open https://www.oracle.com/java/technologies/downloads/ in your browser.
- Select the JDK 21 tab, then download the installer for your operating system (jdk-21_windows-x64_bin.exe for Windows, or the macOS installer for Mac).
- Run the installer and follow the on-screen prompts to complete installation.
After the installer finishes, configure the JAVA_HOME and PATH environment variables so your terminal recognizes the java command.
On Windows:
- Press the Windows key and search for Environment Variables.
- Click Edit the system environment variables.
- Click Environment Variables.
- Under System variables, click New and add JAVA_HOME with the value:
- C:\Program Files\Java\jdk-21
- Find Path in the list, click Edit, and add a new entry:
- %JAVA_HOME%\bin
- Click OK on all dialogs to save.
On macOS:
- Open Terminal and add the following to your shell profile (~/.zshrc or ~/.bash_profile):
export JAVA_HOME=$(/usr/libexec/java_home -v 21)
export PATH=$JAVA_HOME/bin:$PATH
- Save the file and run source ~/.zshrc to apply changes.
Open a new terminal window and verify JDK 21 is active by running:
java -version
You should see output containing java version "21 or similar.
What is JAVA_HOME?
JAVA_HOME is an environment variable that tells other tools (like Maven and Spring Boot) where to find your Java installation. PATH tells your terminal where to find executable commands like java and javac.
Set up Spring Tools for Eclipse and generate your project
Spring Tools for Eclipse is a complete development environment pre-configured for Spring Boot development. It comes with built-in support for Spring Initializr, smart code completion, and runtime diagnostics.
- Open https://spring.io/tools in your browser.
- Scroll to the Eclipse section and click the 5.2.0 download for your operating system (Windows x86_64 for Windows, or the appropriate macOS option).
- Extract the downloaded archive to a folder of your choice (for example, C:\SpringTools on Windows or ~/SpringTools on macOS).
- Open the extracted folder and launch SpringToolSuite4.exe (Windows) or SpringToolSuite4.app (macOS).
- When prompted for a workspace location, accept the default and click Launch.
Why Spring Tools instead of your existing Eclipse?
Spring Tools 5.2.0 is a complete Eclipse distribution pre-configured for Spring development. Downloading the full distribution avoids version compatibility issues and ensures Spring Initializr integration works out of the box.
Now generate your Spring Boot project from Spring Initializr.
- Open https://start.spring.io in your browser.
- Set Project to Maven.
- Set Language to Java.
- Set Spring Boot version to 3.5.15 (or the latest 3.5.x available).
- Under Project Metadata, set Group to com.example and Artifact to turnos-api.
- Set Java version to 21.
- Click ADD DEPENDENCIES and add these three:
- Spring Web.
- Spring Data JPA.
- PostgreSQL Driver.
- Click GENERATE to download the project as a ZIP file.
- Extract the ZIP to a folder on your Desktop (for example, ~/Desktop/turnos-api).
What is Spring Initializr?
Spring Initializr is a web tool that generates a ready-to-run Spring Boot project with your chosen dependencies already wired up in the pom.xml. It saves you from manually writing Maven configuration.
Import the project into Spring Tools.
- In Spring Tools, click File in the top menu bar.
- Click Import.
- Expand Maven and select Existing Maven Projects.
- Click Next.
- Click Browse and navigate to the extracted turnos-api folder.
- You should see pom.xml listed under Projects. Click Finish.
Spring Tools will download all Maven dependencies automatically. Wait for the progress bar in the bottom-right corner to complete.
Import not showing pom.xml?
Make sure you selected the root folder of the extracted project (the one that directly contains pom.xml), not a parent folder. help me fix my Maven import issue
Install Postman and configure your database
You need Postman to send HTTP requests to your API once it is running. You also need a PostgreSQL database for your application to store data in.
- Download Postman from https://www.postman.com/downloads/.
- Run the installer and follow the on-screen prompts.
- When Postman opens, you can skip creating an account for now (click Skip and go to the app if prompted).
Now create the database your application will connect to.
- Open pgAdmin (it was installed with PostgreSQL). Press Cmd+Space (macOS) or the Windows key (Windows) and search for pgAdmin.
- In pgAdmin, expand Servers in the left panel and connect to your local PostgreSQL server (enter your password if prompted).
- Right-click Databases and select Create > Database.
- Enter turnos_db as the database name and click Save.
Why the name turnos_db?
"Turnos" means "shifts" or "appointments" in Spanish. This name reflects the domain of the application you are building. The underscore format follows PostgreSQL naming conventions.
Now configure Spring Boot to connect to your new database.
- In Spring Tools, expand your turnos-api project in the Package Explorer panel on the left.
- Navigate to src/main/resources and double-click application.properties to open it.
- Replace the contents of the file with the following configuration:
spring.datasource.url=jdbc:postgresql://localhost:5432/turnos_db
spring.datasource.username=postgres
spring.datasource.password=your-password-here
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
- Replace your-password-here with the password you set for your PostgreSQL postgres user when you installed PostgreSQL.
- Save the file with Ctrl+S (Windows) or Cmd+S (macOS).
What do these properties do?
- spring.datasource.url tells Spring Boot where your PostgreSQL database lives.
- spring.jpa.hibernate.ddl-auto=update tells JPA to automatically create or update database tables based on your Java code.
- spring.jpa.show-sql=true prints every SQL query to the console so you can see what the application is doing.
Run your application to confirm everything is connected.
- In the Package Explorer, right-click your turnos-api project.
- Select Run As, then click Spring Boot App.
- Watch the Console panel at the bottom of Spring Tools.
You should see a line containing Started TurnosApiApplication in the console output. Your app is now running on port 8080.
- Open your browser and visit http://localhost:8080.
You will see a "Whitelabel Error Page" with a 404 status. This is correct because you have not created any endpoints yet. The error page confirms Spring Boot is running and responding to requests.
Application fails to start?
- If you see a connection error mentioning turnos_db, make sure your PostgreSQL server is running and the database name matches exactly.
- If you see an authentication error, double-check the password in application.properties matches your PostgreSQL postgres user password.
- If the port is already in use, another process is occupying port 8080. Close other running servers or add server.port=8081 to your application.properties.
help me fix my Spring Boot startup error
✔️ Awesome, I've got everything!
Great. Double check you have saved application.properties and that the console shows Started TurnosApiApplication.
ⓧ I'd like to double check the full code
Here is what your pom.xml should look like (generated by Spring Initializr):
<?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.15</version>
<relativeTo/>
</parent>
<groupId>com.example</groupId>
<artifactId>turnos-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>turnos-api</name>
<description>Appointment Booking System API</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.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</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>
Here is your application.properties:
spring.datasource.url=jdbc:postgresql://localhost:5432/turnos_db
spring.datasource.username=postgres
spring.datasource.password=your-password-here
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
And here is your main application class (auto-generated):
package com.example.turnos;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class TurnosApiApplication {
public static void main(String[] args) {
SpringApplication.run(TurnosApiApplication.class, args);
}
}
Your development environment is ready to go. Next up, you will model your business domain by creating Java entities that map directly to database tables.
Model Your Domain
Your Spring Boot application starts and connects to PostgreSQL, but it has no idea what data it needs to store.
Every backend system starts with a domain model. The domain model defines the core data your application works with. For an appointment booking system, that means defining what a "service" looks like and what an "appointment" contains, then giving Spring Data JPA the instructions to create your database tables automatically.
In this step, get ready to:
- Design JPA entity classes that map Java objects to PostgreSQL tables.
- Create repository interfaces that provide database operations with zero implementation code.
- Verify that JPA auto-creates your tables in the database.
Design your JPA entities
Think about what data a service business needs. A "service offered" (haircut, consultation, oil change) has a name, a duration, and a price. An "appointment" has a client name, client email, the service type being booked, a date/time, and a status (scheduled or cancelled).
In JPA, you model these as entity classes with annotations that tell Hibernate how to map each field to a database column.
- In the Package Explorer on the left side of Spring Tools, expand your project turnos-api until you see src/main/java.
- Right-click the com.example.turnos package, then select New, then Package.
- In the Name field, type com.example.turnos.model and click Finish.
What is a Java package?
A package is a folder structure that organizes your Java classes. The model package will hold your entity classes, which represent the tables in your database.
- Right-click the new com.example.turnos.model package, then select New, then Class.
- In the Name field, type ServiceType and click Finish.
- Replace the entire contents of ServiceType.java with the following code:
package com.example.turnos.model;
import jakarta.persistence.*;
@Entity
@Table(name = "service_types")
public class ServiceType {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private Integer durationMinutes;
@Column(nullable = false)
private Double price;
public ServiceType() {}
public ServiceType(String name, Integer durationMinutes, Double price) {
this.name = name;
this.durationMinutes = durationMinutes;
this.price = price;
}
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 Integer getDurationMinutes() { return durationMinutes; }
public void setDurationMinutes(Integer durationMinutes) { this.durationMinutes = durationMinutes; }
public Double getPrice() { return price; }
public void setPrice(Double price) { this.price = price; }
}
What does this code do?
- `@Entity` tells JPA this class maps to a database table.
- `@Table(name = "service_types")` specifies the exact table name in PostgreSQL.
- `@Id` and `@GeneratedValue(strategy = GenerationType.IDENTITY)` mark the primary key and tell PostgreSQL to auto-increment it.
- `@Column(nullable = false)` ensures these fields cannot be stored as null in the database.
- The empty constructor is required by JPA. The parameterized constructor is a convenience for creating new instances in your code.
- Save ServiceType.java with Ctrl+S.
Seeing red underlines on the import?
If jakarta.persistence.* shows an error, your project may not have resolved its Maven dependencies yet. Right-click the project, select Maven, then Update Project, and click OK.
help me fix import errors in my Spring Boot JPA entity class
Now you need an enum that defines the possible statuses for an appointment. An appointment is either scheduled or cancelled.
- Right-click the com.example.turnos.model package, then select New, then Enum.
- In the Name field, type AppointmentStatus and click Finish.
- Replace the entire contents of AppointmentStatus.java with the following code:
package com.example.turnos.model;
public enum AppointmentStatus {
SCHEDULED,
CANCELLED
}
- Save AppointmentStatus.java with Ctrl+S.
Now create the Appointment entity, which is the core of the booking system. This entity references the ServiceType through a relationship, just like a foreign key in a database.
- Right-click the com.example.turnos.model package, then select New, then Class.
- In the Name field, type Appointment and click Finish.
- Replace the entire contents of Appointment.java with the following code:
package com.example.turnos.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "appointments")
public class Appointment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String clientName;
@Column(nullable = false)
private String clientEmail;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "service_type_id", nullable = false)
private ServiceType serviceType;
@Column(nullable = false)
private LocalDateTime dateTime;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private AppointmentStatus status;
public Appointment() {}
public Appointment(String clientName, String clientEmail, ServiceType serviceType, LocalDateTime dateTime) {
this.clientName = clientName;
this.clientEmail = clientEmail;
this.serviceType = serviceType;
this.dateTime = dateTime;
this.status = AppointmentStatus.SCHEDULED;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getClientName() { return clientName; }
public void setClientName(String clientName) { this.clientName = clientName; }
public String getClientEmail() { return clientEmail; }
public void setClientEmail(String clientEmail) { this.clientEmail = clientEmail; }
public ServiceType getServiceType() { return serviceType; }
public void setServiceType(ServiceType serviceType) { this.serviceType = serviceType; }
public LocalDateTime getDateTime() { return dateTime; }
public void setDateTime(LocalDateTime dateTime) { this.dateTime = dateTime; }
public AppointmentStatus getStatus() { return status; }
public void setStatus(AppointmentStatus status) { this.status = status; }
}
What does this code do?
- `@ManyToOne(fetch = FetchType.EAGER)` creates a relationship to the ServiceType table. Many appointments can reference one service type.
- `@JoinColumn(name = "service_type_id")` tells JPA which column holds the foreign key.
- `@Enumerated(EnumType.STRING)` stores the status as the text "SCHEDULED" or "CANCELLED" rather than a number, making your database easier to read.
- The parameterized constructor automatically sets the status to `SCHEDULED` when a new appointment is created.
- Save Appointment.java with Ctrl+S.
Seeing errors on LocalDateTime or AppointmentStatus?
Make sure AppointmentStatus.java is saved in the same com.example.turnos.model package. If LocalDateTime shows an error, check that the import java.time.LocalDateTime is at the top of the file.
help me fix unresolved type errors in my JPA entity
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved before continuing.
ⓧ I'd like to double check the full code
Here are all three model files for comparison:
package com.example.turnos.model;
import jakarta.persistence.*;
@Entity
@Table(name = "service_types")
public class ServiceType {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private Integer durationMinutes;
@Column(nullable = false)
private Double price;
public ServiceType() {}
public ServiceType(String name, Integer durationMinutes, Double price) {
this.name = name;
this.durationMinutes = durationMinutes;
this.price = price;
}
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 Integer getDurationMinutes() { return durationMinutes; }
public void setDurationMinutes(Integer durationMinutes) { this.durationMinutes = durationMinutes; }
public Double getPrice() { return price; }
public void setPrice(Double price) { this.price = price; }
}
package com.example.turnos.model;
public enum AppointmentStatus {
SCHEDULED,
CANCELLED
}
package com.example.turnos.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "appointments")
public class Appointment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String clientName;
@Column(nullable = false)
private String clientEmail;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "service_type_id", nullable = false)
private ServiceType serviceType;
@Column(nullable = false)
private LocalDateTime dateTime;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private AppointmentStatus status;
public Appointment() {}
public Appointment(String clientName, String clientEmail, ServiceType serviceType, LocalDateTime dateTime) {
this.clientName = clientName;
this.clientEmail = clientEmail;
this.serviceType = serviceType;
this.dateTime = dateTime;
this.status = AppointmentStatus.SCHEDULED;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getClientName() { return clientName; }
public void setClientName(String clientName) { this.clientName = clientName; }
public String getClientEmail() { return clientEmail; }
public void setClientEmail(String clientEmail) { this.clientEmail = clientEmail; }
public ServiceType getServiceType() { return serviceType; }
public void setServiceType(ServiceType serviceType) { this.serviceType = serviceType; }
public LocalDateTime getDateTime() { return dateTime; }
public void setDateTime(LocalDateTime dateTime) { this.dateTime = dateTime; }
public AppointmentStatus getStatus() { return status; }
public void setStatus(AppointmentStatus status) { this.status = status; }
}
Create repository interfaces
Your entities define the shape of the data. Now you need a way to actually store and retrieve that data from the database.
In Spring Data JPA, a repository is an interface that extends JpaRepository. By doing this, you get full CRUD operations (create, read, update, delete) with zero implementation code.
- Right-click the com.example.turnos package in the Package Explorer, then select New, then Package.
- In the Name field, type com.example.turnos.repository and click Finish.
- Right-click the new com.example.turnos.repository package, then select New, then Interface.
- In the Name field, type ServiceTypeRepository and click Finish.
- Replace the entire contents of ServiceTypeRepository.java with the following code:
package com.example.turnos.repository;
import com.example.turnos.model.ServiceType;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ServiceTypeRepository extends JpaRepository<ServiceType, Long> {
}
Why is this interface empty?
By extending JpaRepository<ServiceType, Long>, Spring Data JPA automatically provides methods like findAll(), findById(), save(), and delete(). You get full database access without writing a single line of implementation.
- Save ServiceTypeRepository.java with Ctrl+S.
The AppointmentRepository needs more than basic CRUD. It needs custom query methods to find conflicting appointments when validating bookings.
- Right-click the com.example.turnos.repository package, then select New, then Interface.
- In the Name field, type AppointmentRepository and click Finish.
- Replace the entire contents of AppointmentRepository.java with the following code:
package com.example.turnos.repository;
import com.example.turnos.model.Appointment;
import com.example.turnos.model.AppointmentStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
public interface AppointmentRepository extends JpaRepository<Appointment, Long> {
// Find appointments that overlap with a proposed time slot for the same service
@Query("SELECT a FROM Appointment a WHERE a.serviceType.id = :serviceTypeId AND a.status = :status AND a.dateTime > :rangeStart AND a.dateTime < :rangeEnd")
List<Appointment> findConflictingAppointments(
@Param("serviceTypeId") Long serviceTypeId,
@Param("status") AppointmentStatus status,
@Param("rangeStart") LocalDateTime rangeStart,
@Param("rangeEnd") LocalDateTime rangeEnd);
// Find appointments within a date range filtered by status
@Query("SELECT a FROM Appointment a WHERE a.dateTime >= :start AND a.dateTime <= :end AND a.status = :status")
List<Appointment> findByDateRangeAndStatus(
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end,
@Param("status") AppointmentStatus status);
}
What do these custom queries do?
- `findConflictingAppointments` searches for existing appointments that overlap with a proposed booking. It looks for scheduled appointments of the same service type whose start time falls within the range around the proposed time.
- `findByDateRangeAndStatus` retrieves all scheduled appointments for a given date range. You will use this later to filter appointments by day.
- The `@Query` annotation uses JPQL (Java Persistence Query Language), which queries your entities rather than raw tables. This means you write `a.serviceType.id` instead of a SQL join.
- Save AppointmentRepository.java with Ctrl+S.
Red underlines on @Query or @Param?
These annotations come from the Spring Data JPA dependency. If they show as unresolved, right-click the project, select Maven, then Update Project, check Force Update of Snapshots/Releases, and click OK.
help me resolve Spring Data JPA import errors
✔️ Awesome, I've got everything!
Great. Make sure both repository files are saved before continuing.
ⓧ I'd like to double check the full code
Here are both repository files for comparison:
package com.example.turnos.repository;
import com.example.turnos.model.ServiceType;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ServiceTypeRepository extends JpaRepository<ServiceType, Long> {
}
package com.example.turnos.repository;
import com.example.turnos.model.Appointment;
import com.example.turnos.model.AppointmentStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
public interface AppointmentRepository extends JpaRepository<Appointment, Long> {
@Query("SELECT a FROM Appointment a WHERE a.serviceType.id = :serviceTypeId AND a.status = :status AND a.dateTime > :rangeStart AND a.dateTime < :rangeEnd")
List<Appointment> findConflictingAppointments(
@Param("serviceTypeId") Long serviceTypeId,
@Param("status") AppointmentStatus status,
@Param("rangeStart") LocalDateTime rangeStart,
@Param("rangeEnd") LocalDateTime rangeEnd);
@Query("SELECT a FROM Appointment a WHERE a.dateTime >= :start AND a.dateTime <= :end AND a.status = :status")
List<Appointment> findByDateRangeAndStatus(
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end,
@Param("status") AppointmentStatus status);
}
Start the application and verify your tables
Your entities and repositories are ready. When Spring Boot starts, JPA reads your entity annotations and automatically creates the corresponding tables in PostgreSQL because you set spring.jpa.hibernate.ddl-auto=update in your properties file.
- In Spring Tools, right-click your turnos-api project in the Package Explorer.
- Select Run As, then Spring Boot App.
In the Console panel at the bottom of Spring Tools, you should see SQL statements being logged (because you enabled spring.jpa.show-sql=true). Look for lines containing create table followed by service_types and appointments.
- Verify the tables exist in PostgreSQL by opening a psql session or pgAdmin connected to turnos_db.
- If using psql, connect to your database and list tables by running:
psql -U postgres -d turnos_db
Once connected, list your tables by running:
\dt
You should see service_types and appointments in the list of tables.
What if I prefer pgAdmin?
In pgAdmin, expand Servers, then your server, then Databases, then turnos_db, then Schemas, then public, then Tables. You should see both service_types and appointments listed.
Tables not appearing?
Check the Spring Tools Console for error messages. The most common cause is incorrect database credentials in application.properties. Make sure spring.datasource.password matches your PostgreSQL password.
Also confirm the application started successfully by looking for the Started TurnosApiApplication message in the console output.
help me troubleshoot JPA table auto-creation in Spring Boot
Your domain model is locked in. JPA is managing your database tables, and your repositories are ready to handle data operations. Next up, you will build the REST endpoints that let clients interact with your service types through HTTP requests.
Build the Service Type Endpoints
Your data model is in place, but there is no way for the outside world to interact with it yet. A REST API exposes your data through URL endpoints that clients (like a mobile app or a frontend) can call over HTTP.
In this step, you will create a controller that maps HTTP requests to Java methods. You will then test those endpoints in Postman and watch Spring Boot automatically convert between JSON, Java objects, and database rows.
In this step, get ready to:
- Create a REST controller with GET and POST endpoints for service types.
- Test your endpoints with Postman to create and retrieve services.
- Observe how Spring Boot handles JSON-to-Java-to-database conversion automatically.
Create the ServiceTypeController class
A REST controller is the "front door" of your API. Each method inside it maps to a specific URL path and HTTP verb combination. When a request arrives at that URL, Spring Boot calls the matching method and sends the return value back as JSON.
- In Spring Tools, right-click on the com.example.turnos package in the Package Explorer sidebar.
- Select New, then Package.
- Enter com.example.turnos.controller as the name, then click Finish.
- Right-click on the new com.example.turnos.controller package.
- Select New, then Class.
- Enter ServiceTypeController as the class name, then click Finish.
- Replace the generated file contents with the following code:
package com.example.turnos.controller;
import com.example.turnos.model.ServiceType;
import com.example.turnos.repository.ServiceTypeRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/services")
public class ServiceTypeController {
private final ServiceTypeRepository serviceTypeRepository;
// Spring injects the repository automatically via constructor injection
public ServiceTypeController(ServiceTypeRepository serviceTypeRepository) {
this.serviceTypeRepository = serviceTypeRepository;
}
// GET /api/services - returns all service types as JSON
@GetMapping
public List<ServiceType> getAllServices() {
return serviceTypeRepository.findAll();
}
// POST /api/services - accepts JSON body and saves a new service type
@PostMapping
public ResponseEntity<ServiceType> createService(@RequestBody ServiceType serviceType) {
ServiceType saved = serviceTypeRepository.save(serviceType);
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
}
What does this code do?
This controller exposes two endpoints at the base path /api/services.
- @RestController tells Spring this class handles HTTP requests and that return values should be written directly to the response body as JSON.
- @RequestMapping("/api/services") sets the base URL path for all methods inside this class.
- @GetMapping maps HTTP GET requests to getAllServices(), which returns every service type from the database.
- @PostMapping maps HTTP POST requests to createService(), which reads JSON from the request body, saves it, and returns the saved object with a 201 Created status.
- Save ServiceTypeController.java by pressing Ctrl+S.
Why does the controller use the repository directly?
For simple CRUD with no business logic, calling the repository from the controller is perfectly fine. In the next step, you will see how the appointment system adds a service layer between the controller and repository to handle complex validation rules.
Seeing red underlines in the editor?
Make sure your import statements match the code above exactly. If Eclipse shows unresolved imports, right-click in the editor and select Source, then Organize Imports.
help me fix import errors in my Spring Boot controller
Test your endpoints with Postman
Your controller is ready to accept requests. You need to start the application and then use Postman to send HTTP requests to it.
- In Spring Tools, right-click on TurnosApiApplication.java in the Package Explorer.
- Select Run As, then Spring Boot App.
- Wait until you see Started TurnosApiApplication in the Console tab at the bottom.
Your API is now running on port 8080.
- Open Postman.
- Click the + button to create a new request tab.
- Change the HTTP method dropdown to POST.
- Enter this URL in the request URL field:
http://localhost:8080/api/services
- Click the Body tab below the URL field.
- Select raw, then change the dropdown from Text to JSON.
- Paste the following JSON into the body area:
{
"name": "Haircut",
"durationMinutes": 30,
"price": 25.00
}
- Click Send.
You should see a response with status 201 Created and a JSON body that includes an auto-generated id field.
- Now change the HTTP method dropdown to GET (keep the same URL).
- Click Send.
You should see a JSON array containing the Haircut service you just created.
What just happened?
Spring Boot did three things automatically when you sent the POST request.
- It converted the incoming JSON into a Java ServiceType object (deserialization).
- It saved that Java object to the service_types table in PostgreSQL via JPA.
- It converted the saved object (now with an id) back into JSON for the response (serialization).
Getting a 404 or connection refused?
Check the Console tab in Spring Tools. If you see an error about the datasource, make sure PostgreSQL is running and your password in application.properties is correct.
If the console shows no errors but you still get 404, double-check that the URL is exactly http://localhost:8080/api/services with no trailing slash.
help me debug why my Spring Boot API returns 404
Add more services and verify in your database
A real service business offers more than one service. Adding multiple records helps confirm that your API stores and retrieves all of them correctly.
- In Postman, change the method back to POST and send a second service by pasting this JSON body:
{
"name": "Beard Trim",
"durationMinutes": 15,
"price": 12.00
}
- Click Send.
- Send a third service with this body:
{
"name": "Full Colour",
"durationMinutes": 90,
"price": 75.00
}
- Click Send.
- Switch to GET and click Send to see all three services in the response array.
- Open pgAdmin and navigate to turnos_db, then Schemas, then Tables, then right-click service_types and select View/Edit Data, then All Rows.
You should see three rows in the table, each with an auto-generated id, name, duration_minutes, and price columns populated.
Why is this impressive?
You wrote zero conversion code. Spring Boot automatically converted JSON from Postman into Java objects, then converted those Java objects into SQL INSERT statements for PostgreSQL. On the way back out, it reversed the process: SQL rows became Java objects, which became JSON.
This is the power of Spring Boot auto-configuration combined with JPA entity mapping and Jackson JSON serialization.
Not seeing rows in pgAdmin?
Make sure you are looking at the turnos_db database, not the default postgres database. Also confirm your POST requests in Postman returned 201 Created and not an error.
help me troubleshoot missing data in my PostgreSQL table
✔️ Awesome, I've got everything!
Great. Make sure you have saved ServiceTypeController.java and that your app is still running.
ⓧ I'd like to double check the full code
Here is the complete ServiceTypeController.java file:
package com.example.turnos.controller;
import com.example.turnos.model.ServiceType;
import com.example.turnos.repository.ServiceTypeRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/services")
public class ServiceTypeController {
private final ServiceTypeRepository serviceTypeRepository;
public ServiceTypeController(ServiceTypeRepository serviceTypeRepository) {
this.serviceTypeRepository = serviceTypeRepository;
}
@GetMapping
public List<ServiceType> getAllServices() {
return serviceTypeRepository.findAll();
}
@PostMapping
public ResponseEntity<ServiceType> createService(@RequestBody ServiceType serviceType) {
ServiceType saved = serviceTypeRepository.save(serviceType);
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
}
Your API now has a working front door. Next up, you will build the appointment system with real business rules that prevent double-bookings, reject past dates, and enforce business hours.
Build the Appointment System with Business Rules
Your ServiceTypeController proved that Spring Boot can receive HTTP requests and talk to the database. But it has no "brain." Anyone can create a service, no rules are enforced, and there is no logic between the controller and the database.
In this step, you will build the appointment system with real business logic. The system will reject requests that violate rules: no booking outside business hours, no booking in the past, and no double-booking the same time slot.
In this step, get ready to:
- Create a service layer that enforces three business rules before saving an appointment.
- Build a REST controller with endpoints for creating, listing, filtering, and cancelling appointments.
- Test every rule in Postman and confirm invalid requests are rejected with clear error messages.
Create the AppointmentService with business logic
A real booking system needs rules. Think about what could go wrong if a client could book any time they wanted: appointments at 3 AM, double-booked slots, or dates that already passed.
The service layer is where these rules live. It sits between the controller (front door) and the repository (database). Every request passes through the service, which decides whether the request is valid before saving anything.
Why a separate service layer?
You could put validation logic directly in the controller. But as your app grows, multiple controllers might need the same rules. Keeping business logic in a dedicated service class means it is reusable, testable, and easy to find.
The controller handles HTTP concerns (status codes, JSON parsing). The service handles business concerns (is this booking valid?). Each class has one job.
- In the Package Explorer sidebar in Spring Tools, right-click on src/main/java inside your turnos-api project.
- Select New, then Package.
- Enter com.example.turnos.service as the package name and click Finish.
- Right-click the new com.example.turnos.service package, select New, then Class.
- Enter AppointmentService as the class name and click Finish.
- Replace the entire file contents with the following code to set up the service skeleton:
package com.example.turnos.service;
import com.example.turnos.model.Appointment;
import com.example.turnos.model.AppointmentStatus;
import com.example.turnos.model.ServiceType;
import com.example.turnos.repository.AppointmentRepository;
import com.example.turnos.repository.ServiceTypeRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
@Service
public class AppointmentService {
private final AppointmentRepository appointmentRepository;
private final ServiceTypeRepository serviceTypeRepository;
// Business hours: 9 AM to 6 PM
private static final LocalTime BUSINESS_OPEN = LocalTime.of(9, 0);
private static final LocalTime BUSINESS_CLOSE = LocalTime.of(18, 0);
public AppointmentService(AppointmentRepository appointmentRepository, ServiceTypeRepository serviceTypeRepository) {
this.appointmentRepository = appointmentRepository;
this.serviceTypeRepository = serviceTypeRepository;
}
}
What does this code do?
- @Service tells Spring Boot this class contains business logic and should be managed as a bean (auto-created on startup).
- The two final fields are injected through the constructor. Spring Boot sees the constructor parameter types and automatically provides the matching repository instances.
- BUSINESS_OPEN and BUSINESS_CLOSE define the window during which appointments are allowed (9:00 to 18:00).
Now add the public methods that the controller will call. These handle creating, listing, filtering, and cancelling appointments.
- Inside the AppointmentService class (below the constructor's closing brace), add these methods:
public Appointment createAppointment(String clientName, String clientEmail, Long serviceTypeId, LocalDateTime dateTime) {
// Look up the service type from the database
ServiceType serviceType = serviceTypeRepository.findById(serviceTypeId)
.orElseThrow(() -> new IllegalArgumentException("Service type not found with id: " + serviceTypeId));
// Run all three business rule validations
validateBusinessHours(dateTime, serviceType.getDurationMinutes());
validateNotInPast(dateTime);
validateNoOverlap(serviceTypeId, dateTime, serviceType.getDurationMinutes());
// If all validations pass, create and save the appointment
Appointment appointment = new Appointment(clientName, clientEmail, serviceType, dateTime);
return appointmentRepository.save(appointment);
}
public List<Appointment> getAllAppointments() {
return appointmentRepository.findAll();
}
public List<Appointment> getAppointmentsByDate(LocalDateTime start, LocalDateTime end) {
return appointmentRepository.findByDateRangeAndStatus(start, end, AppointmentStatus.SCHEDULED);
}
public Appointment cancelAppointment(Long id) {
Appointment appointment = appointmentRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Appointment not found with id: " + id));
// Change status instead of deleting - preserves history
appointment.setStatus(AppointmentStatus.CANCELLED);
return appointmentRepository.save(appointment);
}
What does this code do?
- createAppointment() is the most important method. It looks up the service, runs three validation checks, and only saves the appointment if all checks pass.
- cancelAppointment() changes the status to CANCELLED instead of deleting the row. This preserves booking history for reporting.
- getAppointmentsByDate() only returns SCHEDULED appointments, filtering out cancelled ones automatically.
The createAppointment() method calls three validation methods that do not exist yet. These are the enforcement rules that make this system production-worthy.
- Below the cancelAppointment() method, add the three private validation methods:
private void validateBusinessHours(LocalDateTime dateTime, int durationMinutes) {
LocalTime appointmentTime = dateTime.toLocalTime();
LocalTime appointmentEnd = appointmentTime.plusMinutes(durationMinutes);
// Reject if appointment starts before opening or ends after closing
if (appointmentTime.isBefore(BUSINESS_OPEN) || appointmentEnd.isAfter(BUSINESS_CLOSE)) {
throw new IllegalArgumentException("Appointment must be within business hours (09:00 - 18:00)");
}
}
private void validateNotInPast(LocalDateTime dateTime) {
// Reject if the requested time has already passed
if (dateTime.isBefore(LocalDateTime.now())) {
throw new IllegalArgumentException("Cannot book appointments in the past");
}
}
private void validateNoOverlap(Long serviceTypeId, LocalDateTime dateTime, int durationMinutes) {
// Search for any existing appointments that would overlap with this time window
LocalDateTime rangeStart = dateTime.minusMinutes(durationMinutes);
LocalDateTime rangeEnd = dateTime.plusMinutes(durationMinutes);
List<Appointment> conflicts = appointmentRepository.findConflictingAppointments(
serviceTypeId, AppointmentStatus.SCHEDULED, rangeStart, rangeEnd);
if (!conflicts.isEmpty()) {
throw new IllegalArgumentException("This time slot conflicts with an existing appointment for the selected service");
}
}
How does overlap detection work?
Two appointments overlap if one starts before the other ends. For a 30-minute haircut at 10:00, the occupied window is 10:00 to 10:30. Any appointment that starts during that window would conflict.
The method searches a range from (start minus duration) to (start plus duration). This catches conflicts in both directions. Back-to-back bookings are allowed because the query uses strict less-than/greater-than (exclusive bounds).
- Save AppointmentService.java by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Check the Problems tab at the bottom of Spring Tools. You should see no errors for this file.
Seeing red errors in AppointmentService?
- Check that the closing brace } for the class is at the very end of the file. Every method must be inside the class body.
- Make sure all imports at the top match exactly. Missing imports for LocalTime or LocalDateTime will cause errors on every method that uses them.
- Verify the package name is com.example.turnos.service and matches the folder structure.
Help me fix compile errors in my AppointmentService class.
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before continuing to the controller.
ⓧ I'd like to double check the full code
package com.example.turnos.service;
import com.example.turnos.model.Appointment;
import com.example.turnos.model.AppointmentStatus;
import com.example.turnos.model.ServiceType;
import com.example.turnos.repository.AppointmentRepository;
import com.example.turnos.repository.ServiceTypeRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
@Service
public class AppointmentService {
private final AppointmentRepository appointmentRepository;
private final ServiceTypeRepository serviceTypeRepository;
private static final LocalTime BUSINESS_OPEN = LocalTime.of(9, 0);
private static final LocalTime BUSINESS_CLOSE = LocalTime.of(18, 0);
public AppointmentService(AppointmentRepository appointmentRepository, ServiceTypeRepository serviceTypeRepository) {
this.appointmentRepository = appointmentRepository;
this.serviceTypeRepository = serviceTypeRepository;
}
public Appointment createAppointment(String clientName, String clientEmail, Long serviceTypeId, LocalDateTime dateTime) {
ServiceType serviceType = serviceTypeRepository.findById(serviceTypeId)
.orElseThrow(() -> new IllegalArgumentException("Service type not found with id: " + serviceTypeId));
validateBusinessHours(dateTime, serviceType.getDurationMinutes());
validateNotInPast(dateTime);
validateNoOverlap(serviceTypeId, dateTime, serviceType.getDurationMinutes());
Appointment appointment = new Appointment(clientName, clientEmail, serviceType, dateTime);
return appointmentRepository.save(appointment);
}
public List<Appointment> getAllAppointments() {
return appointmentRepository.findAll();
}
public List<Appointment> getAppointmentsByDate(LocalDateTime start, LocalDateTime end) {
return appointmentRepository.findByDateRangeAndStatus(start, end, AppointmentStatus.SCHEDULED);
}
public Appointment cancelAppointment(Long id) {
Appointment appointment = appointmentRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Appointment not found with id: " + id));
appointment.setStatus(AppointmentStatus.CANCELLED);
return appointmentRepository.save(appointment);
}
private void validateBusinessHours(LocalDateTime dateTime, int durationMinutes) {
LocalTime appointmentTime = dateTime.toLocalTime();
LocalTime appointmentEnd = appointmentTime.plusMinutes(durationMinutes);
if (appointmentTime.isBefore(BUSINESS_OPEN) || appointmentEnd.isAfter(BUSINESS_CLOSE)) {
throw new IllegalArgumentException("Appointment must be within business hours (09:00 - 18:00)");
}
}
private void validateNotInPast(LocalDateTime dateTime) {
if (dateTime.isBefore(LocalDateTime.now())) {
throw new IllegalArgumentException("Cannot book appointments in the past");
}
}
private void validateNoOverlap(Long serviceTypeId, LocalDateTime dateTime, int durationMinutes) {
LocalDateTime rangeStart = dateTime.minusMinutes(durationMinutes);
LocalDateTime rangeEnd = dateTime.plusMinutes(durationMinutes);
List<Appointment> conflicts = appointmentRepository.findConflictingAppointments(
serviceTypeId, AppointmentStatus.SCHEDULED, rangeStart, rangeEnd);
if (!conflicts.isEmpty()) {
throw new IllegalArgumentException("This time slot conflicts with an existing appointment for the selected service");
}
}
}
Create the controller and request DTO
The controller is the "front door" of your API. Each method maps to a URL plus an HTTP verb combination. But first, you need a way to describe what data the client sends when booking an appointment.
A DTO (Data Transfer Object) defines the shape of incoming JSON requests. It keeps your API contract separate from your database entities.
- Create a new package called com.example.turnos.dto by right-clicking src/main/java and selecting New, then Package.
- Right-click the new package, select New, then Class. Name it CreateAppointmentRequest and click Finish.
- Replace the entire file contents with the following code:
package com.example.turnos.dto;
import java.time.LocalDateTime;
// A Java record automatically creates constructor, getters, equals, hashCode, and toString
public record CreateAppointmentRequest(
String clientName,
String clientEmail,
Long serviceTypeId,
LocalDateTime dateTime
) {}
What is a Java record?
A record is a compact way to define a class that just holds data. Java automatically generates the constructor, getter methods, equals(), hashCode(), and toString(). Records are immutable, meaning once created, their values cannot change.
Notice this DTO takes a serviceTypeId (a number) instead of a full ServiceType object. The client only knows the ID. The service layer looks up the full entity.
- Save CreateAppointmentRequest.java.
Now create the controller that exposes your appointment endpoints to the outside world.
- In the com.example.turnos.controller package, create a new class called AppointmentController.
- Replace the file contents with this code to set up the controller shell and GET endpoints:
package com.example.turnos.controller;
import com.example.turnos.dto.CreateAppointmentRequest;
import com.example.turnos.model.Appointment;
import com.example.turnos.service.AppointmentService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/appointments")
public class AppointmentController {
private final AppointmentService appointmentService;
public AppointmentController(AppointmentService appointmentService) {
this.appointmentService = appointmentService;
}
// GET /api/appointments - returns all appointments
@GetMapping
public List<Appointment> getAllAppointments() {
return appointmentService.getAllAppointments();
}
// GET /api/appointments/date/2026-07-10 - filter by specific date
@GetMapping("/date/{date}")
public List<Appointment> getAppointmentsByDate(@PathVariable String date) {
LocalDate localDate = LocalDate.parse(date);
LocalDateTime start = localDate.atStartOfDay();
LocalDateTime end = localDate.atTime(23, 59, 59);
return appointmentService.getAppointmentsByDate(start, end);
}
}
What does this code do?
- @RequestMapping("/api/appointments") sets the base URL for all endpoints in this controller. Every method's URL builds on top of this.
- The controller delegates to AppointmentService for all logic. It never touches the database directly.
- @PathVariable extracts the {date} segment from the URL and passes it as a String parameter.
- Below the getAppointmentsByDate() method, add the POST and PATCH endpoints that create and cancel appointments:
// POST /api/appointments - create a new appointment (with validation)
@PostMapping
public ResponseEntity<?> createAppointment(@RequestBody CreateAppointmentRequest request) {
try {
Appointment appointment = appointmentService.createAppointment(
request.clientName(),
request.clientEmail(),
request.serviceTypeId(),
request.dateTime());
return ResponseEntity.status(HttpStatus.CREATED).body(appointment);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
// PATCH /api/appointments/1/cancel - cancel an appointment by ID
@PatchMapping("/{id}/cancel")
public ResponseEntity<?> cancelAppointment(@PathVariable Long id) {
try {
Appointment cancelled = appointmentService.cancelAppointment(id);
return ResponseEntity.ok(cancelled);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
Why the try-catch pattern?
When a validation fails, AppointmentService throws an IllegalArgumentException. The controller catches it and returns a 400 Bad Request with the error message as JSON. This gives the client a clear explanation of what went wrong.
ResponseEntity<?> (with the wildcard ?) lets the method return different types: an Appointment on success, or a Map with an error message on failure.
- Save AppointmentController.java.
- Check the Problems tab for errors. You should see none.
Seeing import errors in the controller?
- Confirm CreateAppointmentRequest.java is saved in the com.example.turnos.dto package. The controller imports it from that exact location.
- Verify AppointmentService.java is in com.example.turnos.service. If the package name is different, the import will fail.
Help me fix import issues in my AppointmentController.
✔️ Awesome, I've got everything!
Great. Double check the file is saved before restarting the application.
ⓧ I'd like to double check the full code
package com.example.turnos.controller;
import com.example.turnos.dto.CreateAppointmentRequest;
import com.example.turnos.model.Appointment;
import com.example.turnos.service.AppointmentService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/appointments")
public class AppointmentController {
private final AppointmentService appointmentService;
public AppointmentController(AppointmentService appointmentService) {
this.appointmentService = appointmentService;
}
@GetMapping
public List<Appointment> getAllAppointments() {
return appointmentService.getAllAppointments();
}
@GetMapping("/date/{date}")
public List<Appointment> getAppointmentsByDate(@PathVariable String date) {
LocalDate localDate = LocalDate.parse(date);
LocalDateTime start = localDate.atStartOfDay();
LocalDateTime end = localDate.atTime(23, 59, 59);
return appointmentService.getAppointmentsByDate(start, end);
}
@PostMapping
public ResponseEntity<?> createAppointment(@RequestBody CreateAppointmentRequest request) {
try {
Appointment appointment = appointmentService.createAppointment(
request.clientName(),
request.clientEmail(),
request.serviceTypeId(),
request.dateTime());
return ResponseEntity.status(HttpStatus.CREATED).body(appointment);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@PatchMapping("/{id}/cancel")
public ResponseEntity<?> cancelAppointment(@PathVariable Long id) {
try {
Appointment cancelled = appointmentService.cancelAppointment(id);
return ResponseEntity.ok(cancelled);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
}
Test your business rules with Postman
Your code is complete. Now you will prove that the business rules actually work by testing every rule and confirming invalid requests are rejected.
- Restart your Spring Boot application. In Spring Tools, click the red square (stop) in the Console tab, then right-click the project and select Run As, then Spring Boot App.
- Confirm the console shows "Started TurnosApiApplication" with no errors.
Application fails to start?
- Look for the first red error line in the console stack trace. It usually names the file and line number causing the failure.
- If you see a bean creation error for AppointmentService, check that your constructor parameters match the repository types exactly.
Help me debug my Spring Boot application startup failure.
Now test the happy path first, then deliberately break each rule.
- Switch back to Postman.
- Create a new request. Set the method to POST and the URL to http://localhost:8080/api/appointments.
- Click the Body tab, select raw, and choose JSON from the dropdown.
- Paste this JSON body to create a valid appointment (use tomorrow's date at 10:00 AM):
{
"clientName": "Maria Garcia",
"clientEmail": "maria@example.com",
"serviceTypeId": 1,
"dateTime": "2026-07-08T10:00:00"
}
Use a future date
Replace the date with tomorrow's date or any future date. The format is YYYY-MM-DDTHH:MM:SS. The serviceTypeId must match an ID from your service_types table (check with GET /api/services if unsure).
- Click Send. You should see a 201 Created response with the appointment data, including an auto-generated id and status of SCHEDULED.
Now test each business rule. Start with the overlap rule.
- Send the exact same POST request again (same time, same service).
- You should see a 400 Bad Request response with the message: "This time slot conflicts with an existing appointment for the selected service".
Next, test the business hours rule.
- Change the dateTime in your JSON body to a future date at 3 AM, for example 2026-07-08T03:00:00.
- Click Send. You should see: "Appointment must be within business hours (09:00 - 18:00)".
Finally, test the past-date rule.
- Change the dateTime to a date in the past, for example 2025-01-01T10:00:00.
- Click Send. You should see: "Cannot book appointments in the past".
Now verify the remaining endpoints work.
- Create a new GET request to http://localhost:8080/api/appointments and click Send. You should see a JSON array containing the appointment you created.
- Test filtering by date. Send a GET request to http://localhost:8080/api/appointments/date/2026-07-08 (using the date from your valid appointment). You should see only appointments for that date.
- Test cancellation. Create a new PATCH request to http://localhost:8080/api/appointments/1/cancel (replace 1 with your appointment's actual ID).
- Click Send. The response should show the appointment with its status changed to CANCELLED.
Getting a 404 on the appointment endpoints?
- Make sure you restarted the application after saving the new files. Spring Boot only picks up new controllers on startup.
- Check that your controller has @RestController and @RequestMapping("/api/appointments") annotations on the class.
- Verify the URL is exactly http://localhost:8080/api/appointments with no trailing slash.
Help me figure out why my /api/appointments endpoint returns 404.
Secret mission
Build an Available Time Slots Endpoint
Your API can create, list, and cancel appointments. But what about helping clients find WHEN they can book? Build an algorithmic endpoint that calculates and returns all available time slots for a given date and service type.
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 entirely locally, so there are no ongoing costs.
Resources you used:
- turnos_db PostgreSQL database (with service_types and appointments tables).
- turnos-api project folder.
- Spring Tools for Eclipse (extracted distribution).
- JDK 21 (installed via Oracle installer).
- Postman (installed desktop app).
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the API with more features.
- Your Spring Boot application continues running on port 8080 until you stop it.
- Your turnos_db database retains all your test data.
- JDK 21, Spring Tools, and Postman remain installed for future Java projects.
Pause - I'll come back to this later
Stop your running processes to free up memory. Your files, database, and tools stay in place for when you return.
- In Spring Tools for Eclipse, click the red square (stop) button in the Console panel to stop the Spring Boot application.
- Close Spring Tools for Eclipse.
- Close Postman.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to clean up everything.
- Stop the running Spring Boot application in Spring Tools (click the red stop button in the Console panel), then close Spring Tools.
- Drop the database by opening pgAdmin or psql and running:
DROP DATABASE turnos_db;
- Delete the turnos-api project folder from wherever you extracted it.
- Delete the Spring Tools for Eclipse folder. Since it is an extracted distribution (not an installed program), delete the entire folder where you extracted it.
- Uninstall JDK 21. Open Settings, select Apps, then Installed apps. Search for Java and click Uninstall.
- Uninstall Postman. In the same Installed apps list, search for Postman and click Uninstall.
- Remove the JAVA_HOME and PATH environment variables you configured. Open Settings, search for environment variables, and delete the JAVA_HOME entry. Remove the JDK path from your Path variable.
Nice Work!
Nice Work!
Nice work! You just built a production-style REST API for an appointment booking system using Spring Boot, Spring Data JPA, and PostgreSQL.
You've learned how to:
- Build a full REST API with Spring Boot connected to PostgreSQL, with controllers, services, repositories, and DTOs working together.
- Model a real business domain using JPA entities and relationships, with automatic table creation and JSON serialization handled by Spring Boot.
- Implement a service layer with business logic validation that enforces real-world rules: no double-booking, business hours only, and no past-date appointments.
- Secret Mission: Built an algorithmic endpoint that calculates and returns available time slots for a given date and service type.
Ready to quiz yourself?