Course: Java CLI Build Prepared for: Amigoscode Academy Students Level: Junior/Mid-Level Java Engineering Repository: github.com/amigoscode/java-master-class
Build a Car Booking CLI System from scratch and progressively refactor it through 6 phases, applying core Java concepts and software engineering best practices at each stage.
The student starts with a basic implementation using arrays and no design patterns, then iteratively improves the codebase by introducing interfaces and dependency injection, collections, streams, Maven with unit testing, and finally transforming the application into a Spring Boot REST API. Each phase is unlocked after completing the relevant Amigoscode courses.
graph LR
A[Phase 1<br>Initial Implementation] --> B[Phase 2<br>Interfaces & DI]
B --> C[Phase 3<br>Lists]
C --> D[Phase 4<br>Streams]
D --> E[Phase 5<br>Maven & Unit Tests]
E --> F[Phase 6<br>Spring Boot]
Each phase is preceded by the courses the student must complete before starting it.
graph TD
C1[Java for Beginners] --> C2[Java for Developers]
C2 --> P1[Phase 1 - Initial Implementation]
P1 --> C3[Java OOP]
C3 --> P2[Phase 2 - Interfaces & DI]
P2 --> C4[Java Data Structures]
C4 --> P3[Phase 3 - Lists]
P3 --> C5[Functional Programming]
C5 --> C6[Java Streams]
C6 --> P4[Phase 4 - Streams]
P4 --> C7[Maven]
C7 --> C8[Java Unit Testing]
C8 --> C9[Mocking]
C9 --> P5[Phase 5 - Maven & Unit Tests]
P5 --> C10[Spring Boot for Beginners]
C10 --> C11[Spring Framework]
C11 --> P6[Phase 6 - Spring Boot]
style P1 fill:#2B5797,color:#fff
style P2 fill:#2B5797,color:#fff
style P3 fill:#2B5797,color:#fff
style P4 fill:#2B5797,color:#fff
style P5 fill:#2B5797,color:#fff
style P6 fill:#2B5797,color:#fff
| Phase | Required Courses |
|---|---|
| Phase 1 - Initial Implementation | Java for Beginners, Java for Developers |
| Phase 2 - Interfaces & DI | Java OOP |
| Phase 3 - Lists | Java Data Structures |
| Phase 4 - Streams | Functional Programming, Java Streams |
| Phase 5 - Maven & Unit Tests | Maven, Java Unit Testing, Mocking |
| Phase 6 - Spring Boot | Spring Boot for Beginners, Spring Framework |
As an admin for a car company, the student must develop a CLI system that supports the following operations:
1 - Book Car
2 - Delete Booking
3 - View All User Booked Cars
4 - View All Bookings
5 - View Available Cars
6 - View Available Electric Cars
7 - View All Users
8 - Exit
| ID | Requirement | Description |
|---|---|---|
| FR-01 | Book a Car | System prompts for user ID, car selection, start date and end date. Price is calculated from the car's rental price per day. A car that is already booked cannot be booked again |
| FR-02 | Delete Booking | Cancel an existing booking by booking ID, making the car available again |
| FR-03 | View User Bookings | Display all cars booked by a specific user |
| FR-04 | View All Bookings | Display every booking in the system |
| FR-05 | View Available Cars | List all cars not currently booked |
| FR-06 | View Electric Cars | Filter and display only available electric cars |
| FR-07 | View All Users | List all registered users |
| FR-08 | Exit | Gracefully terminate the application |
- Each user must have a unique identifier (UUID) generated by the system
- The system must support static seed data for users and cars
- Bookings are created at runtime (not pre-seeded)
- The CLI must loop until the user selects Exit
Each phase follows the same workflow:
graph LR
A[Create Branch] --> B[Implement Phase]
B --> C[Push to Remote]
C --> D[Create PR]
D --> E[Request Review]
E --> F[Squash & Merge<br>to main]
- Create a new branch for the phase:
git checkout -b <branch-name> - Implement the requirements
- Push to remote:
git push origin <branch-name> - Create a pull request:
gh pr create - Request a review from the Amigoscode Academy community
- Once approved, squash and merge to
main
classDiagram
class User {
-UUID id
-String name
+getId() UUID
+getName() String
}
class Car {
-UUID id
-String regNumber
-BigDecimal rentalPricePerDay
-Brand brand
-boolean isElectric
+getId() UUID
+getRegNumber() String
+getRentalPricePerDay() BigDecimal
+getBrand() Brand
+isElectric() boolean
}
class CarBooking {
-UUID id
-User user
-Car car
-LocalDate startDate
-LocalDate endDate
-BigDecimal price
-BookingStatus status
+getId() UUID
+getUser() User
+getCar() Car
+getStartDate() LocalDate
+getEndDate() LocalDate
+getPrice() BigDecimal
+getStatus() BookingStatus
}
class Brand {
<<enumeration>>
TESLA
AUDI
MERCEDES
TOYOTA
}
class BookingStatus {
<<enumeration>>
ACTIVE
CANCELLED
COMPLETED
}
CarBooking --> User
CarBooking --> Car
CarBooking --> BookingStatus
Car --> Brand
| Phase | Branch | Key Concepts | Difficulty |
|---|---|---|---|
| 1 | initial-implementation |
POJOs, arrays, basic OOP, CLI I/O | Beginner |
| 2 | interfaces-and-di |
Interfaces, polymorphism, serialization, file I/O, constructor injection | Mid |
| 3 | lists |
Collections framework, generics | Mid |
| 4 | streams |
Streams API, lambdas, method references | Mid |
| 5 | maven-and-tests |
Build tools, dependency management, JUnit 5, Mockito | Mid-Advanced |
| 6 | spring-boot |
Spring Boot, REST APIs, Spring Data JPA | Advanced |
The starter template is available at github.com/amigoscode/java-master-class. There are two ways to set up the project:
- Go to github.com/amigoscode/java-master-class
- Click "Use this template" → "Create a new repository"
- Name the repository (e.g.
java-master-class) and create it under your own GitHub account - Clone your new repository:
git clone git@github.com:<your-username>/java-master-class.git- Clone the template repository:
git clone git@github.com:amigoscode/java-master-class.git-
Create a new empty repository on your GitHub account (e.g.
java-master-class) -
Re-point the remote to your own repository:
cd java-master-class
git remote set-url origin git@github.com:<your-username>/java-master-class.git- Push to your new remote:
git push -u origin main- Create the first branch:
git checkout -b initial-implementation- Create a package with your name and move
Main.javainside it:
src/com/<yourname>/Main.java
For example, if your name is Franco: src/com/franco/Main.java
The starter Main.java contains TODOs to guide the first steps:
// TODO 1. create a new branch called initial-implementation
// TODO 2. create a package with your name. i.e com.franco and move this file inside the new package
// TODO 3. implement https://amigoscode.com/learn/java-cli-build/lectures/3a83ecf3-e837-4ae5-85a8-f8ae3f60f7f5
public class Main {
public static void main(String[] args) {
System.out.println("Java Master Class");
}
}After moving, update the package declaration:
package com.franco;
public class Main {
public static void main(String[] args) {
System.out.println("Java Master Class");
}
}- Begin implementing Phase 1
Prerequisite courses: Java for Beginners, Java for Developers
Branch: initial-implementation
Build the complete CLI application from scratch.
Architecture:
graph TB
subgraph Presentation
M[Main.java<br>CLI Menu Loop]
end
subgraph Service Layer
CBS[CarBookingService]
CS[CarService]
US[UserService]
end
subgraph Data Access Layer
CBD[CarBookingDao]
CD[CarDao]
UD[UserDao]
end
M --> CBS
CBS --> CS
CBS --> US
CBS --> CBD
CS --> CD
US --> UD
CBD --> BA["CarBooking[]"]
CD --> CA["Car[]"]
UD --> UA["User[]"]
Constraints:
| Constraint | Details |
|---|---|
| Data storage | Arrays only |
| Design patterns | None (no interfaces, no DI) |
| Build tool | None (plain Java) |
| Testing | Manual only |
| Collections | Arrays only (no Lists) |
| Streams | Not allowed |
Package structure:
src/
└── com/
└── amigoscode/
├── Main.java
├── car/
│ ├── Brand.java
│ ├── Car.java
│ ├── CarDao.java
│ └── CarService.java
├── user/
│ ├── User.java
│ ├── UserDao.java
│ └── UserService.java
└── booking/
├── CarBooking.java
├── CarBookingDao.java
└── CarBookingService.java
Key tasks:
- Create POJO classes:
User,Car,CarBooking,Brandenum - Create DAO classes:
UserDao,CarDao,CarBookingDao - Create service classes for business logic
- Implement the CLI menu loop in
Main.java - Seed static data for users and cars
- Implement all 8 menu options
Important: When booking a car, the system must check if the car is already booked. If it is, reject the booking and inform the user. A car becomes available again only after its booking is deleted.
public void bookCar(User user, Car car) {
// 1. Get all current bookings
// 2. Check if any booking already has this car
// 3. If yes → throw exception or print error
// 4. If no → create and save the booking
}Seed data example:
private static final User[] users;
static {
users = new User[]{
new User(UUID.fromString("8ca51d2b-aaaf-4bf2-834a-e02964e10fc3"), "James"),
new User(UUID.fromString("b10d126a-3608-4980-9f9c-aa179f5cebc3"), "Jamila")
};
}UUID generation: Use java.util.UUID or generate via terminal:
for i in {1..10}; do
uuid=$(uuidgen | tr '[:upper:]' '[:lower:]')
echo $uuid
donegit add .
git commit -m "feat: implement car booking cli system"
git push origin initial-implementation
gh pr createRequest a review from the Amigoscode Academy community. Once approved, squash and merge the PR to main.
Prerequisite courses: Java OOP
Branch: interfaces-and-di
After squashing and merging Phase 1, sync your local main and create the new branch:
git checkout main
git pull origin main
git checkout -b interfaces-and-diRefactor the data access layer to use interfaces and apply dependency injection throughout the codebase.
Target architecture:
graph TB
subgraph Presentation
M[Main.java<br>CLI Menu Loop]
end
subgraph Service Layer
CBS[CarBookingService]
CS[CarService]
US[UserService]
end
subgraph Data Access Layer
CBD["CarBookingDao (interface)"]
CD["CarDao (interface)"]
UD["UserDao (interface)"]
end
subgraph CarBooking Implementations
CBDA[CarBookingArrayDataAccessService]
CBDF[CarBookingFileDataAccessService]
end
subgraph Array Implementations
CDA[CarArrayDataAccessService]
UDA[UserArrayDataAccessService]
end
M --> CBS
CBS --> CS
CBS --> US
CBS --> CBD
CS --> CD
US --> UD
CBD --> CBDA
CBD --> CBDF
CD --> CDA
UD --> UDA
Package structure:
src/
└── com/
└── amigoscode/
├── Main.java
├── car/
│ ├── Brand.java
│ ├── Car.java
│ ├── CarDao.java (interface: getCars, findCarById)
│ ├── CarArrayDataAccessService.java (implements CarDao)
│ └── CarService.java
├── user/
│ ├── User.java
│ ├── UserDao.java (interface: getUsers, findUserById)
│ ├── UserArrayDataAccessService.java (implements UserDao)
│ └── UserService.java
└── booking/
├── CarBooking.java
├── CarBookingDao.java (interface: getBookings, findBookingById,
│ saveBooking, deleteBooking)
├── CarBookingArrayDataAccessService.java (implements CarBookingDao)
├── CarBookingFileDataAccessService.java (implements CarBookingDao - serialization)
└── CarBookingService.java
Extract DAO classes into interfaces and keep the existing array-backed implementations.
CarBookingDao interface:
public interface CarBookingDao {
CarBooking[] getBookings();
CarBooking findBookingById(UUID bookingId);
void saveBooking(CarBooking booking);
void deleteBooking(UUID bookingId);
}CarDao interface:
public interface CarDao {
Car[] getCars();
Car findCarById(UUID carId);
}UserDao interface:
public interface UserDao {
User[] getUsers();
User findUserById(UUID userId);
}Rename the existing DAO classes to *ArrayDataAccessService and have them implement the new interfaces.
graph LR
subgraph Before
A1[CarBookingService] --> B1[CarBookingDao class]
end
subgraph After
A2[CarBookingService] --> B2[CarBookingDao interface]
B2 --> C2[CarBookingArrayDataAccessService]
B2 --> D2[CarBookingFileDataAccessService]
end
Create a second implementation for CarBookingDao that persists bookings to a file using Java serialization.
The student must figure out how to serialize and deserialize Java objects to/from files. This is a research task — serialization has not been covered in prior courses.
Hints:
- POJOs must implement
java.io.Serializable - Use
ObjectOutputStreamto write objects to a file - Use
ObjectInputStreamto read objects back - The file-based implementation should read from / write to a file (e.g.
bookings.dat)
Bonus: If the student wants extra practice, they can also create file-based implementations for
CarDaoandUserDao. This is optional.
Example structure:
public class CarBookingFileDataAccessService implements CarBookingDao {
private final String filePath;
public CarBookingFileDataAccessService(String filePath) {
this.filePath = filePath;
}
@Override
public void saveBooking(CarBooking booking) {
// Read existing bookings from file
// Add new booking
// Write all bookings back to file
}
@Override
public void deleteBooking(UUID bookingId) {
// Read existing bookings from file
// Remove booking with matching ID
// Write remaining bookings back to file
}
// ... other methods
}Remove all new keyword instantiations from service classes. All dependencies must be injected via constructors.
Before:
public class CarBookingService {
private final CarBookingDao carBookingDao = new CarBookingDao();
private final CarService carService = new CarService();
}After:
public class CarBookingService {
private final CarBookingDao carBookingDao;
private final CarService carService;
public CarBookingService(CarBookingDao carBookingDao, CarService carService) {
this.carBookingDao = carBookingDao;
this.carService = carService;
}
}Wiring in Main.java — the student can now swap between array and file implementations for bookings:
public static void main(String[] args) {
// Swap booking implementation here
CarBookingDao carBookingDao = new CarBookingFileDataAccessService("bookings.dat");
// CarBookingDao carBookingDao = new CarBookingArrayDataAccessService();
CarDao carDao = new CarArrayDataAccessService();
CarService carService = new CarService(carDao);
UserDao userDao = new UserArrayDataAccessService();
UserService userService = new UserService(userDao);
CarBookingService carBookingService = new CarBookingService(
carBookingDao, carService
);
}git add .
git commit -m "refactor: extract dao interfaces and apply dependency injection"
git push origin interfaces-and-di
gh pr createRequest a review from the Amigoscode Academy community. Once approved, squash and merge the PR to main.
Prerequisite courses: Java Data Structures
Branch: lists
git checkout main
git pull origin main
git checkout -b listsReplace all array usage with java.util.List throughout the entire codebase.
Before:
User[] users = userService.getUsers();
CarBooking[] bookings = carBookingService.getBookings();After:
List<User> users = userService.getUsers();
List<CarBooking> bookings = carBookingService.getBookings();Impact: This change touches every layer — DAO interfaces, all implementations (array and file-based), services, and the menu display logic. Use ArrayList for mutable lists and Collections.emptyList() for empty returns.
Update all DAO interfaces to return and accept List<T> instead of arrays.
git add .
git commit -m "refactor: replace arrays with lists"
git push origin lists
gh pr createRequest a review from the Amigoscode Academy community. Once approved, squash and merge the PR to main.
Prerequisite courses: Functional Programming, Java Streams
Branch: streams
git checkout main
git pull origin main
git checkout -b streamsRefactor imperative loops to use the Java Streams API for filtering, mapping, and collecting data.
Before (imperative):
public List<Car> getAllElectricCars() {
List<Car> cars = getAllCars();
if (cars.size() == 0) {
return Collections.emptyList();
}
List<Car> electricCars = new ArrayList<>();
for (Car car : cars) {
if (car.isElectric()) {
electricCars.add(car);
}
}
return electricCars;
}After (streams):
public List<Car> getAllElectricCars() {
return getAllCars().stream()
.filter(Car::isElectric)
.collect(Collectors.toList());
}Apply streams wherever filtering, transformation, or collection operations occur.
git add .
git commit -m "refactor: replace imperative loops with streams"
git push origin streams
gh pr createRequest a review from the Amigoscode Academy community. Once approved, squash and merge the PR to main.
Prerequisite courses: Maven, Java Unit Testing, Mocking
Branch: maven-and-tests
git checkout main
git pull origin main
git checkout -b maven-and-testsPackage structure (after Maven conversion):
src/
├── main/
│ ├── java/
│ │ └── com/
│ │ └── amigoscode/
│ │ ├── Main.java
│ │ ├── car/
│ │ │ ├── Brand.java
│ │ │ ├── Car.java
│ │ │ ├── CarDao.java
│ │ │ ├── CarArrayDataAccessService.java
│ │ │ ├── CarFakerDataAccessService.java
│ │ │ └── CarService.java
│ │ ├── user/
│ │ │ ├── User.java
│ │ │ ├── UserDao.java
│ │ │ ├── UserArrayDataAccessService.java
│ │ │ ├── UserFakerDataAccessService.java
│ │ │ └── UserService.java
│ │ └── booking/
│ │ ├── CarBooking.java
│ │ ├── CarBookingDao.java
│ │ ├── CarBookingArrayDataAccessService.java
│ │ ├── CarBookingFileDataAccessService.java
│ │ └── CarBookingService.java
│ └── resources/
│ └── users.csv
├── test/
│ └── java/
│ └── com/
│ └── amigoscode/
│ ├── booking/
│ │ ├── CarBookingServiceTest.java
│ │ ├── CarBookingArrayDataAccessServiceTest.java
│ │ └── CarBookingFileDataAccessServiceTest.java
│ ├── car/
│ │ ├── CarServiceTest.java
│ │ └── CarArrayDataAccessServiceTest.java
│ └── user/
│ ├── UserServiceTest.java
│ └── UserArrayDataAccessServiceTest.java
└── pom.xml
Convert the project from a plain Java project to a Maven project.
Tasks:
- Create standard Maven directory structure (
src/main/java,src/main/resources) - Create
pom.xmlwith project coordinates - Move data files to
src/main/resources/ - Update file reading to use classpath:
// Before - hardcoded path relative to project root (breaks in Maven)
File file = new File("src/com/amigoscode/users.csv");
// After - loads from classpath (src/main/resources/)
File file = new File(
getClass().getClassLoader().getResource("users.csv").getPath()
);How this works:
getClass()— gets the current class.getClassLoader()— gets the classloader that loaded this class.getResource("users.csv")— looks forusers.csvon the classpath (Maven compilessrc/main/resources/into the classpath automatically).getPath()— converts the resource URL to a file path string
In Maven, files under src/main/resources/ are copied to the classpath at build time, so this approach works regardless of where the project is run from.
Add the JavaFaker dependency and create new DAO implementations that generate random data.
<dependency>
<groupId>com.github.javafaker</groupId>
<artifactId>javafaker</artifactId>
<version>1.0.2</version>
</dependency>Tasks:
- Create
UserFakerDataAccessServiceimplementingUserDao— generate 20 random users usingfaker.name().fullName() - Create
CarFakerDataAccessServiceimplementingCarDao— generate random cars using faker (e.g.faker.lorem().word()for reg numbers, randomBrandenum values, random prices, randomisElectric)
Note: JavaFaker does not have a built-in car provider. The student should explore what faker offers and get creative with generating realistic car data.
Example:
public class UserFakerDataAccessService implements UserDao {
@Override
public List<User> getUsers() {
Faker faker = new Faker();
List<User> users = new ArrayList<>();
for (int i = 0; i < 20; i++) {
users.add(new User(UUID.randomUUID(), faker.name().fullName()));
}
return users;
}
// ... other methods
}These can be swapped in via dependency injection in Main.java:
UserDao userDao = new UserFakerDataAccessService();
// UserDao userDao = new UserArrayDataAccessService();Write unit tests for all services and DAO implementations (array and file only — no need to test faker DAOs).
Stack:
- JUnit 5
- Mockito
Maven dependencies:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.5.0</version>
<scope>test</scope>
</dependency>What to test:
Services (mock all dependencies with Mockito):
| Class | Dependencies to mock | What to test |
|---|---|---|
CarBookingService |
CarBookingDao, CarService, UserService |
Book car, delete booking, get bookings, find by ID |
CarService |
CarDao |
Get all cars, find by ID, get electric cars |
UserService |
UserDao |
Get all users, find by ID |
Array DAOs (no mocking needed — test directly):
| Class | What to test |
|---|---|
CarBookingArrayDataAccessService |
Save, delete, get all, find by ID |
CarArrayDataAccessService |
Get all cars, find by ID |
UserArrayDataAccessService |
Get all users, find by ID |
File DAO (use temp files):
| Class | What to test |
|---|---|
CarBookingFileDataAccessService |
Save booking to file, delete booking from file, read bookings from file |
Use JUnit's @TempDir to create a temporary file for each test so tests don't interfere with each other:
class CarBookingFileDataAccessServiceTest {
@TempDir
Path tempDir;
private CarBookingFileDataAccessService underTest;
@BeforeEach
void setUp() {
String filePath = tempDir.resolve("bookings.dat").toString();
underTest = new CarBookingFileDataAccessService(filePath);
}
@Test
void itShouldSaveAndRetrieveBooking() {
// Given
CarBooking booking = new CarBooking(...);
// When
underTest.saveBooking(booking);
List<CarBooking> bookings = underTest.getBookings();
// Then
assertThat(bookings).hasSize(1);
assertThat(bookings.get(0).getId()).isEqualTo(booking.getId());
}
@Test
void itShouldDeleteBooking() {
// Given
CarBooking booking = new CarBooking(...);
underTest.saveBooking(booking);
// When
underTest.deleteBooking(booking.getId());
// Then
assertThat(underTest.getBookings()).isEmpty();
}
}git add .
git commit -m "build: convert to maven and add unit tests"
git push origin maven-and-tests
gh pr createRequest a review from the Amigoscode Academy community. Once approved, squash and merge the PR to main.
Transform the CLI application into a Spring Boot REST API in a new repository.
Architecture:
graph TB
subgraph HTTP Layer
C[REST Client]
end
subgraph Controller Layer
CC[CarBookingController]
end
subgraph Service Layer
CBS[CarBookingService]
CS[CarService]
US[UserService]
end
subgraph Data Access Layer
CBD[CarBookingRepository]
CD[CarRepository]
UD[AppUserRepository]
end
subgraph Database
DB[(H2 In-Memory Database)]
end
C -->|HTTP| CC
CC --> CBS
CBS --> CS
CBS --> US
CBS --> CBD
CS --> CD
US --> UD
CBD --> DB
CD --> DB
UD --> DB
The Spring Boot starter template is available at github.com/amigoscode/java-master-class-spring-boot. This is a separate repository from the CLI project.
Create or clone the starter (same options as Getting Started):
# Option A: Use template on GitHub then clone
git clone git@github.com:<your-username>/java-master-class-spring-boot.git
# Option B: Clone and re-point
git clone git@github.com:amigoscode/java-master-class-spring-boot.git
cd java-master-class-spring-boot
git remote set-url origin git@github.com:<your-username>/java-master-class-spring-boot.git
git push -u origin mainThe starter comes pre-configured with:
- Spring Boot 4.0.2 with Java 25
- Spring Web for REST endpoints
- Spring Data JPA for database access
- H2 embedded in-memory database
application.ymlconfigured to run on port 8080
Verify the project runs:
./mvnw spring-boot:runThe application should start on http://localhost:8080. The H2 console is available at http://localhost:8080/h2-console.
Copy POJOs and enums from the CLI project into the Spring Boot project:
Brand.java→src/main/java/com/<yourname>/car/Car.java→src/main/java/com/<yourname>/car/User.java→ rename toAppUser.java→src/main/java/com/<yourname>/user/CarBooking.java→src/main/java/com/<yourname>/booking/
Convert POJOs to JPA entities. The DAO interfaces from the CLI project are no longer needed — Spring Data JPA replaces them with repositories.
@Entity
@Table(name = "car")
public class Car {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false, unique = true)
private String regNumber;
@Column(nullable = false)
private BigDecimal rentalPricePerDay;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Brand brand;
@Column(nullable = false)
private boolean isElectric;
// constructors, getters, setters
}Note:
Useris a reserved keyword in most SQL databases and can conflict with Spring Data JPA. Rename the class toAppUserand map it to theapp_usertable.
@Entity
@Table(name = "app_user")
public class AppUser {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false)
private String name;
// constructors, getters, setters
}@Entity
@Table(name = "car_booking")
public class CarBooking {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne
@JoinColumn(name = "app_user_id", nullable = false)
private AppUser user;
@ManyToOne
@JoinColumn(name = "car_id", nullable = false)
private Car car;
@Column(nullable = false)
private LocalDate startDate;
@Column(nullable = false)
private LocalDate endDate;
@Column(nullable = false)
private BigDecimal price;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private BookingStatus status;
// constructors, getters, setters
}public enum BookingStatus {
ACTIVE,
CANCELLED,
COMPLETED
}Entity Relationship Diagram:
erDiagram
APP_USER {
UUID id PK
VARCHAR name
}
CAR {
UUID id PK
VARCHAR reg_number
DECIMAL rental_price_per_day
VARCHAR brand
BOOLEAN is_electric
}
CAR_BOOKING {
UUID id PK
UUID app_user_id FK
UUID car_id FK
DATE start_date
DATE end_date
DECIMAL price
VARCHAR status
}
APP_USER ||--o{ CAR_BOOKING : "has many"
CAR ||--o{ CAR_BOOKING : "has many"
- An AppUser can have many CarBookings (one-to-many)
- A Car can have many CarBookings (one-to-many)
- A CarBooking belongs to exactly one AppUser and one Car (many-to-one)
This means CAR_BOOKING is the join table that holds app_user_id and car_id as foreign keys. In JPA this is modelled with @ManyToOne on the CarBooking entity.
Entity tables (H2 database):
| Table | Column | Type | Constraints | Description |
|---|---|---|---|---|
| app_user | id | UUID | PK, auto-generated | Unique identifier |
| name | VARCHAR | NOT NULL | User's display name | |
| car | id | UUID | PK, auto-generated | Unique identifier |
| reg_number | VARCHAR | NOT NULL, UNIQUE | Vehicle registration number | |
| rental_price_per_day | DECIMAL | NOT NULL | Daily rental cost | |
| brand | VARCHAR | NOT NULL | Enum: TESLA, AUDI, MERCEDES, TOYOTA | |
| is_electric | BOOLEAN | NOT NULL | Whether the car is electric | |
| car_booking | id | UUID | PK, auto-generated | Unique identifier |
| app_user_id | UUID | FK → app_user, NOT NULL | The user who booked | |
| car_id | UUID | FK → car, NOT NULL | The car that was booked | |
| start_date | DATE | NOT NULL | Booking start date | |
| end_date | DATE | NOT NULL | Booking end date | |
| price | DECIMAL | NOT NULL | Calculated: rental_price_per_day × number of days | |
| status | VARCHAR | NOT NULL | Enum: ACTIVE, CANCELLED, COMPLETED |
No UNIQUE constraint on car_id — the same car can be rebooked after its previous booking is deleted. The double-booking prevention is handled at the service level only.
Replace the DAO interfaces with Spring Data JPA repositories:
public interface CarBookingRepository extends JpaRepository<CarBooking, UUID> {
List<CarBooking> findByUserId(UUID userId);
}public interface CarRepository extends JpaRepository<Car, UUID> {
}public interface AppUserRepository extends JpaRepository<AppUser, UUID> {
}Use DTOs for request and response bodies instead of exposing entities directly.
Request DTOs:
public record CarBookingRequest(UUID userId, UUID carId, LocalDate startDate, LocalDate endDate) {}Response DTOs:
public record CarResponse(
UUID id,
String regNumber,
BigDecimal rentalPricePerDay,
Brand brand,
boolean isElectric
) {}public record CarBookingResponse(
UUID id,
String userName,
String carRegNumber,
Brand carBrand,
LocalDate startDate,
LocalDate endDate,
BigDecimal price,
BookingStatus status
) {}Create DTOs for each entity and map between entities and DTOs in the service layer.
Annotate services with @Service and inject repositories:
@Service
public class CarBookingService {
private final CarBookingRepository carBookingRepository;
private final CarService carService;
private final UserService userService;
public CarBookingService(CarBookingRepository carBookingRepository,
CarService carService,
UserService userService) {
this.carBookingRepository = carBookingRepository;
this.carService = carService;
this.userService = userService;
}
public CarBookingResponse bookCar(CarBookingRequest request) {
// TODO
}
}bookCar requirements:
- Validate user and car exist
- Prevent double booking — only
ACTIVEbookings count. Ideally enforce at DB level with a partial unique index e.g.UNIQUE(car_id) WHERE status = 'ACTIVE', but H2 does not support partial indexes - Validate end date is after start date
- Calculate price:
rentalPricePerDay × number of days - Save booking with status
ACTIVE - Map to response DTO and return
Create REST controllers with endpoints:
@RestController
@RequestMapping("api/v1/bookings")
public class CarBookingController {
private final CarBookingService carBookingService;
public CarBookingController(CarBookingService carBookingService) {
this.carBookingService = carBookingService;
}
@PostMapping
public ResponseEntity<CarBookingResponse> bookCar(@RequestBody CarBookingRequest request) {
// ...
}
@DeleteMapping("{bookingId}")
public void deleteBooking(@PathVariable UUID bookingId) {
// ...
}
}Endpoint mapping:
| HTTP Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/bookings |
Book a car |
| DELETE | /api/v1/bookings/{bookingId} |
Delete a booking |
| GET | /api/v1/bookings/user/{userId} |
View user's bookings |
| GET | /api/v1/bookings |
View all bookings |
| GET | /api/v1/cars/available |
View available cars |
| GET | /api/v1/cars/available/electric |
View available electric cars |
| GET | /api/v1/users |
View all users |
sequenceDiagram
participant Client
participant Controller
participant Service
participant Repository
participant DB as Database
Client->>Controller: POST /api/v1/bookings
Controller->>Service: bookCar(request)
Service->>Repository: findById(userId)
Repository->>DB: SELECT
DB-->>Repository: User
Service->>Repository: findById(carId)
Repository->>DB: SELECT
DB-->>Repository: Car
Service->>Repository: save(booking)
Repository->>DB: INSERT
DB-->>Repository: CarBooking
Service-->>Controller: CarBookingResponse
Controller-->>Client: 201 Created
Seed the database with initial users and cars on application startup using a CommandLineRunner:
@Bean
CommandLineRunner commandLineRunner(AppUserRepository appUserRepository,
CarRepository carRepository) {
return args -> {
AppUser james = new AppUser("James");
AppUser jamila = new AppUser("Jamila");
appUserRepository.saveAll(List.of(james, jamila));
Car tesla = new Car("TE-001", new BigDecimal("29.99"), Brand.TESLA, true);
Car audi = new Car("AU-002", new BigDecimal("24.99"), Brand.AUDI, false);
Car mercedes = new Car("ME-003", new BigDecimal("34.99"), Brand.MERCEDES, false);
Car toyota = new Car("TO-004", new BigDecimal("19.99"), Brand.TOYOTA, false);
Car teslaX = new Car("TE-005", new BigDecimal("39.99"), Brand.TESLA, true);
carRepository.saveAll(List.of(tesla, audi, mercedes, toyota, teslaX));
};
}Place this bean in the Main class (annotated with @SpringBootApplication). The data is inserted into H2 on every startup since the database is in-memory.
Write unit tests for services and integration tests for controllers.
Services — mock repositories with Mockito (same approach as Phase 5):
| Class | Dependencies to mock | What to test |
|---|---|---|
CarBookingService |
CarBookingRepository, CarService, UserService |
Book car, delete booking, get bookings, find by user |
CarService |
CarRepository |
Get all cars, find by ID, get available, get electric |
UserService |
AppUserRepository |
Get all users, find by ID |
Package structure:
src/
├── main/
│ ├── java/
│ │ └── com/
│ │ └── <yourname>/
│ │ ├── Main.java (@SpringBootApplication)
│ │ ├── car/
│ │ │ ├── Brand.java
│ │ │ ├── Car.java (@Entity)
│ │ │ ├── CarRepository.java (extends JpaRepository)
│ │ │ ├── CarResponse.java (DTO)
│ │ │ ├── CarService.java (@Service)
│ │ │ └── CarController.java (@RestController)
│ │ ├── user/
│ │ │ ├── AppUser.java (@Entity)
│ │ │ ├── AppUserRepository.java (extends JpaRepository)
│ │ │ ├── UserResponse.java (DTO)
│ │ │ ├── UserService.java (@Service)
│ │ │ └── UserController.java (@RestController)
│ │ └── booking/
│ │ ├── BookingStatus.java (enum: ACTIVE, CANCELLED, COMPLETED)
│ │ ├── CarBooking.java (@Entity)
│ │ ├── CarBookingRepository.java (extends JpaRepository)
│ │ ├── CarBookingRequest.java (DTO)
│ │ ├── CarBookingResponse.java (DTO)
│ │ ├── CarBookingService.java (@Service)
│ │ └── CarBookingController.java (@RestController)
│ └── resources/
│ └── application.yml
├── test/
│ └── java/
│ └── com/
│ └── <yourname>/
│ ├── booking/
│ │ └── CarBookingServiceTest.java
│ ├── car/
│ │ └── CarServiceTest.java
│ └── user/
│ └── UserServiceTest.java
└── pom.xml
git add .
git commit -m "feat: implement spring boot rest api"
git push origin main
gh pr createRequest a review from the Amigoscode Academy community. Once approved, squash and merge the PR to main.
Each phase has a reference implementation available:
| Phase | Commit |
|---|---|
| Phase 1 - Initial Implementation | 3de9b85 |
| Phase 2 - Interfaces & DI | 4389fb2, 09fc1e8 |
| Phase 3 - Lists | 7454dbf |
| Phase 4 - Streams | 3dea1d0 |
| Phase 5 - Maven & Unit Tests | db69db8 |