Medical Clinic Billing System in Java

Build a console clinic system using Java 17 records, sealed classes, and pattern matching.

Introduction

30 Second Summary

Every medical clinic juggles the same puzzle: patients need appointments, different services cost different amounts, and insurance makes every invoice a calculation exercise. Building software that handles this logic is a classic enterprise Java challenge.

In this project, you will build a console-based Medical Clinic System using Java 17 features like records, sealed interfaces, and pattern matching to schedule appointments, prevent double-bookings, and generate itemized invoices with insurance calculations.

What You'll Build

When you run your finished system, you get an interactive terminal menu where you register patients, schedule appointments with multiple service types, and complete visits to see a formatted invoice printed with per-service line items, insurance discounts, and a final total.

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

  • A working patient registration system where you add patients with insurance tiers (Gold/Silver/Bronze/None) and see them listed using Java records' auto-generated output.
  • An appointment scheduler that rejects double-bookings by throwing a custom checked exception when you try to book the same patient at the same time twice.
  • A smart billing engine that uses sealed interfaces and pattern matching to charge Consultations, Lab Tests, and Procedures differently, then applies insurance discounts, copays, and late-cancellation penalties to produce a formatted invoice.
  • Secret Mission: Add a Provider entity and filter appointments by provider using streams.

Are there any prerequisites?

You should be comfortable with basic Java syntax (variables, methods, control flow, and classes). You also need familiarity with compiling and running Java from the terminal. No frameworks or external libraries are required.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. Taking a moment to articulate the goal helps you stay oriented as you wire together multiple Java 17 features across a layered architecture.

Set Up the Project Structure

A real Java application organizes code into packages that reflect its architecture. Your clinic system needs separate packages for data models, business logic, error handling, and the user interface.

In this step, you will create the directory structure that mirrors this layered architecture. Then you will confirm that javac can compile and java can run a multi-package project from your terminal.

In this step, get ready to:
  • Create the clinic-billing project folder with a layered package structure.
  • Write a minimal Main.java and compile it.
  • Run the compiled program and confirm output in the terminal.
Create the project directory tree

Java packages map directly to folder paths on disk. A class in the com.clinic.model package must live inside a com/clinic/model/ directory.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the full project directory tree by running this command:
mkdir -p clinic-billing/src/com/clinic/{model,service,exception,ui}

What does this command do?

The -p flag tells mkdir to create all parent directories along the path. The brace expansion {model,service,exception,ui} generates four separate subdirectories in one line.

  • model/ holds your data types (Patient, Appointment, Invoice).
  • service/ holds your business logic classes.
  • exception/ holds custom exception classes.
  • ui/ holds the console interface.
  • Move into your new project folder by running:
cd clinic-billing
  • Confirm the directory structure exists by running:
find src -type d

You should see all the directories listed, including src/com/clinic/model, src/com/clinic/service, src/com/clinic/exception, and src/com/clinic/ui.

Not seeing the directories?

  • Make sure you ran the mkdir command from ~/Desktop. If you were in a different folder, the clinic-billing directory was created there instead.
  • On Windows PowerShell, brace expansion does not work. Run four separate mkdir commands instead, one for each subdirectory.
  • Help me troubleshoot my directory structure
Write the Main class

Every Java application needs an entry point. You will create a minimal Main.java that prints a startup message to confirm the toolchain works end-to-end.

  • Open your text editor or IDE.
  • Create a new file at src/com/clinic/Main.java inside your clinic-billing folder and paste the following code:
package com.clinic;

public class Main {
    public static void main(String[] args) {
        System.out.println("Medical Clinic System starting...");
    }
}

What does this code do?

  • package com.clinic; declares that this class belongs to the com.clinic package. The directory path must match this declaration.
  • public static void main(String[] args) is the standard entry point the JVM looks for when you run a class.
  • The println call prints a message so you can confirm everything compiled and ran correctly.
  • Save Main.java.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file at exactly clinic-billing/src/com/clinic/Main.java.

ⓧ I'd like to double check the full code

package com.clinic;

public class Main {
    public static void main(String[] args) {
        System.out.println("Medical Clinic System starting...");
    }
}
Compile and run the project

Java is a compiled language. You must run javac to turn your .java source files into .class bytecode before the JVM can execute them.

  • Make sure your terminal is inside the clinic-billing folder (from the previous substep).
  • Compile all Java source files into an out/ directory by running this command:
javac -d out $(find src -name "*.java")

What does this command do?

  • javac is the Java compiler.
  • -d out tells the compiler to place all generated .class files into the out/ directory, preserving the package structure.
  • $(find src -name "*.java") finds every .java file under src/ and passes them all to the compiler.

If the command completes with no output, that means compilation succeeded. Before you run the program, what do you expect it to print?

  • Run the compiled program by executing:
java -cp out com.clinic.Main

You should see this printed in your terminal:

Medical Clinic System starting...

What does this command do?

  • java launches the JVM to execute compiled bytecode.
  • -cp out sets the classpath to the out/ directory so the JVM knows where to find your .class files.
  • com.clinic.Main is the fully-qualified class name the JVM uses to locate and run the main method.

Seeing an error instead?

  • If you see error: file not found from javac, make sure you are running the command from inside clinic-billing/ (not from src/ or Desktop/).
  • If you see Could not find or load main class, check that your Main.java file starts with package com.clinic; and lives at src/com/clinic/Main.java.
  • If find is not recognized, you may be on Windows PowerShell. Use javac -d out src\com\clinic\Main.java as an alternative.
  • Help me debug my javac compilation error

Your project skeleton is in place and the toolchain is working. Next up, you will build the core data types that represent patients and their insurance tiers.

Build the Domain Model and Register Patients

In the previous step, you confirmed your project compiles and runs. Now you need the core data types that the entire clinic system will operate on.

Before you can schedule or bill anything, you need patients and insurance tiers. Building Patient as a record and InsuranceTier as an enum with fields gives you an early visible win. You can register a patient and see it printed immediately.

In this step, get ready to:
  • Create an InsuranceTier enum and Patient record with validation.
  • Build a PatientService that stores and retrieves patients.
  • Wire up a console menu to add and list patients interactively.
Create the data model classes

Every patient has an insurance tier that determines their coverage percentage and copay amount. In Java 17, enums can carry fields and methods, making them perfect for encoding this business logic right in the type.

  • Create a new file called InsuranceTier.java inside the src/com/clinic/model/ directory, and paste the following code:
package com.clinic.model;

public enum InsuranceTier {
    GOLD(80, 20.00),
    SILVER(60, 35.00),
    BRONZE(40, 50.00),
    NONE(0, 0.00);

    private final int coveragePercent;
    private final double copay;

    InsuranceTier(int coveragePercent, double copay) {
        this.coveragePercent = coveragePercent;
        this.copay = copay;
    }

    public int getCoveragePercent() {
        return coveragePercent;
    }

    public double getCopay() {
        return copay;
    }
}

What does this code do?

This isn't a plain enum with just names. Each constant (GOLD, SILVER, BRONZE, NONE) carries two values: a coverage percentage and a copay dollar amount.

The constructor assigns those values when the enum loads, and the getter methods let other classes read them. Later, the billing engine will use these to compute discounts automatically.

  • Save InsuranceTier.java.

Next, you need a way to represent a patient. Java 17 records give you an immutable data carrier with auto-generated equals(), hashCode(), and toString() in a single line of declaration.

  • Create a new file called Patient.java inside src/com/clinic/model/ and paste this code:
package com.clinic.model;

public record Patient(String id, String name, String phone, InsuranceTier insuranceTier) {
    // Compact constructor validates input before assignment
    public Patient {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("Patient name cannot be blank");
        }
        if (id == null || id.isBlank()) {
            throw new IllegalArgumentException("Patient ID cannot be blank");
        }
    }
}

What is a compact constructor?

Records auto-generate a constructor from their component list. A compact constructor (the one without parentheses in the signature) runs validation logic before those fields are assigned. If someone passes a blank name, the record throws an exception instead of storing invalid data.

This pattern appears on the 1Z0-829 exam because it tests whether you understand how records enforce invariants.

  • Save Patient.java.
  • Compile both model files to verify they have no syntax errors by running:
javac -d out $(find src -name "*.java")
  • You should see no errors. Confirm the compiled classes exist by running:
ls out/com/clinic/model/

You should see InsuranceTier.class and Patient.class in the output.

Seeing a compilation error?

  • Check that InsuranceTier.java is in src/com/clinic/model/ (not src/com/clinic/). The package declaration must match the folder path.
  • Make sure Patient.java has package com.clinic.model; on line 1. Without it, the compiler cannot find InsuranceTier.

Still stuck? Help me debug this Java compilation error.

Now you need a service class that can store, find, and list patients.

  • Create a new file called PatientService.java inside src/com/clinic/service/ and paste this code:
package com.clinic.service;

import com.clinic.model.Patient;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

public class PatientService {
    // In-memory storage for patients
    private final List<Patient> patients = new ArrayList<>();

    public void addPatient(Patient patient) {
        patients.add(patient);
    }

    // Returns Optional to force callers to handle "not found"
    public Optional<Patient> findById(String id) {
        return patients.stream()
                .filter(p -> p.id().equals(id))
                .findFirst();
    }

    public List<Patient> getAllPatients() {
        return List.copyOf(patients);
    }
}

What does this code do?

  • The ArrayList stores patients in memory. No database needed for this project.
  • findById returns an Optional<Patient> instead of null, forcing callers to explicitly handle the case where a patient does not exist.
  • List.copyOf() returns an unmodifiable copy, protecting the internal list from outside changes.
  • Save PatientService.java.
  • Compile again to verify the service class links to the model correctly:
javac -d out $(find src -name "*.java")

No errors means the service can successfully import and use your Patient record.

Cannot find symbol: Patient?

  • Verify that PatientService.java has the import statement import com.clinic.model.Patient; at the top.
  • Confirm you are running the javac command from the clinic-billing root folder, not from inside src/.
Build the interactive console menu

With the data layer ready, you need a way to interact with it. You will build a console UI class that uses switch expressions (arrow syntax) to dispatch menu choices.

  • Create a new file called ConsoleUI.java inside src/com/clinic/ui/. Start by pasting the class shell with the menu loop:
package com.clinic.ui;

import com.clinic.model.*;
import com.clinic.service.PatientService;

import java.util.Scanner;

public class ConsoleUI {
    private final Scanner scanner = new Scanner(System.in);
    private final PatientService patientService = new PatientService();

    public void start() {
        System.out.println("=== Medical Clinic System ===");
        boolean running = true;
        while (running) {
            printMainMenu();
            String choice = scanner.nextLine().trim();
            // Switch expression with arrow syntax (Java 14+)
            switch (choice) {
                case "1" -> addPatient();
                case "2" -> listPatients();
                case "0" -> { running = false; System.out.println("Goodbye!"); }
                default -> System.out.println("Invalid option. Try again.");
            }
        }
    }

    private void printMainMenu() {
        System.out.print("""
                --- Main Menu ---
                1. Add Patient
                2. List Patients
                0. Exit
                Choose an option: """);
    }

What is a switch expression?

The arrow syntax (case "1" ->) eliminates fall-through bugs. Each case executes only its own code without needing a break statement. The printMainMenu method uses a text block (triple-quote string) for clean multiline formatting.

  • In the same file, directly below the printMainMenu() method's closing brace, add the addPatient() method:
    private void addPatient() {
        System.out.print("Patient ID: ");
        String id = scanner.nextLine().trim();
        System.out.print("Patient Name: ");
        String name = scanner.nextLine().trim();
        System.out.print("Phone: ");
        String phone = scanner.nextLine().trim();
        System.out.print("Insurance Tier (GOLD/SILVER/BRONZE/NONE): ");
        String tierInput = scanner.nextLine().trim().toUpperCase();
        InsuranceTier tier;
        try {
            tier = InsuranceTier.valueOf(tierInput);
        } catch (IllegalArgumentException e) {
            System.out.println("Invalid tier. Defaulting to NONE.");
            tier = InsuranceTier.NONE;
        }
        try {
            var patient = new Patient(id, name, phone, tier);
            patientService.addPatient(patient);
            System.out.println("Patient added: " + patient);
        } catch (IllegalArgumentException e) {
            System.out.println("Error: " + e.getMessage());
        }
    }

What does this code do?

  • Each prompt uses scanner.nextLine().trim() consistently. This avoids the classic Scanner bug where mixing nextInt() and nextLine() skips input.
  • InsuranceTier.valueOf() converts the string to an enum constant. If the user types something invalid, the catch block defaults to NONE.
  • The second try/catch handles the record's compact constructor throwing an exception on blank input.
  • Below the addPatient() method, add the listPatients() method and then close the class with a final }:
    private void listPatients() {
        var patients = patientService.getAllPatients();
        if (patients.isEmpty()) { System.out.println("No patients registered."); return; }
        System.out.println("\n--- Patients ---");
        patients.forEach(p -> System.out.printf("[%s] %s | %s | Insurance: %s%n", p.id(), p.name(), p.phone(), p.insuranceTier()));
    }
}

What does this code do?

This method calls getAllPatients() on the service, checks if the list is empty, and formats each patient using printf with the record's accessor methods (p.id(), p.name(), etc.). Records auto-generate accessors named after the fields.

  • Save ConsoleUI.java.

Now update Main.java to launch the console UI instead of printing a test message.

  • Open src/com/clinic/Main.java and find this line:
        System.out.println("Medical Clinic System starting...");
  • Replace the entire file contents with this updated version:
package com.clinic;

import com.clinic.ui.ConsoleUI;

public class Main {
    public static void main(String[] args) {
        var ui = new ConsoleUI();
        ui.start();
    }
}

What changed?

Instead of printing a test message, Main now creates a ConsoleUI instance with var (local variable type inference) and calls start() to enter the menu loop.

  • Save Main.java and compile the entire project:
javac -d out $(find src -name "*.java")

No errors means all five files link together correctly.

Seeing method not found errors?

  • Make sure your ConsoleUI.java has the closing } brace for the class at the very end. Each method must sit inside the class body.
  • Verify the import com.clinic.model.*; line is present at the top of ConsoleUI.java. Without it, the compiler cannot find InsuranceTier or Patient.

Still stuck? Help me fix this compilation error in my ConsoleUI class.

✔️ Awesome, I've got everything!

Great. Make sure you have saved all five files before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm your files match these chunks, in order:

  • InsuranceTier.java in model/ with the four enum constants and getter methods.
  • Patient.java in model/ as a record with compact constructor validation.
  • PatientService.java in service/ with addPatient, findById, and getAllPatients methods.
  • ConsoleUI.java in ui/ with all three sections (class shell, addPatient, listPatients).
  • Main.java updated to instantiate ConsoleUI and call start().
Register your first patient

Before you run the app, think about what you expect to see. You added a record with four fields and a compact constructor. What will Java print when you call toString() on a record?

  • Run the clinic system:
java -cp out com.clinic.Main

You should see the menu appear with options 1, 2, and 0.

  • Type 1 and press Enter to add a patient.
  • Enter P001 for the Patient ID.
  • Enter Alice for the Patient Name.
  • Enter 555-1234 for the Phone.
  • Enter GOLD for the Insurance Tier.

You should see confirmation that the patient was added, including the record's auto-generated toString() output.

  • Now type 2 and press Enter to list patients.

You should see output like:

[P001] Alice | 555-1234 | Insurance: GOLD
  • Type 0 and press Enter to exit.

Why does the record print that way?

Records auto-generate a toString() that prints the class name followed by all field values in square brackets, like Patient[id=P001, name=Alice, phone=555-1234, insuranceTier=GOLD]. You see that in the "Patient added" confirmation line. The list display uses a custom format instead.

App starts but crashes on input?

  • If you see a NoSuchElementException, make sure you are running the app from a regular terminal (not a pipe or redirected input).
  • If the menu prints twice or skips input, confirm every input read uses scanner.nextLine().trim() and not scanner.next() or scanner.nextInt().

Need help? My clinic app crashes when I try to enter patient data.

You have a working patient registry with an interactive menu. Next up, you will add appointments and discover what happens when the billing engine treats every service the same way.

Schedule Appointments and Prevent Double-Bookings

You can register patients, but there is no way to schedule visits yet. In this step you will add appointments with a flat service model that tracks a name and a cost.

You will also build double-booking protection: when you try to schedule the same patient at the same time twice, the system throws a custom DoubleBookingException. Every service bills at its flat cost for now. That limitation is intentional and sets up the next step.

In this step, get ready to:
  • Create the model types for appointments, statuses, and services.
  • Build an appointment service with stream-based conflict detection.
  • Wire scheduling into the console menu and trigger a double-booking error.
Create the appointment data types

Before you can schedule anything, you need types to represent appointment status and the services a patient receives. You will create two small model files first.

  • Create AppointmentStatus.java inside src/com/clinic/model/ with this code:
package com.clinic.model;

// Enum with a display-friendly label for each appointment state
public enum AppointmentStatus {
    SCHEDULED("Scheduled"),
    COMPLETED("Completed"),
    CANCELLED("Cancelled"),
    LATE_CANCELLED("Late Cancelled");

    private final String displayName;

    AppointmentStatus(String displayName) {
        this.displayName = displayName;
    }

    public String getDisplayName() { return displayName; }
}
  • Create MedicalService.java in the same model/ directory with this code:
package com.clinic.model;

// Simple record to hold a service name and its flat cost
public record MedicalService(String name, double baseCost) {
}

What do these types do?

AppointmentStatus is an enum with behavior. Each constant stores a human-readable displayName string that the rest of the app can print without translating raw constant names.

MedicalService is a record with just two fields. Every service bills at this flat cost for now. Step 4 replaces this with a richer type hierarchy.

  • Save both files.
  • Confirm they compile by running:
javac -d out $(find src -name "*.java")

You should see no errors. The compiler exits silently on success.

Seeing a compilation error?

Check that your package declaration matches the folder path exactly: package com.clinic.model; lives inside src/com/clinic/model/.

Make sure the enum constants in AppointmentStatus end with a semicolon after the last constant (LATE_CANCELLED("Late Cancelled");).

Help me fix my AppointmentStatus or MedicalService compilation error.

Next you need the Appointment class that ties a patient, a date/time, and a list of services together.

  • Create Appointment.java inside src/com/clinic/model/ and paste in the class structure:
package com.clinic.model;

import java.time.LocalDateTime;
import java.util.List;

// Mutable class: status changes over the appointment lifecycle
public class Appointment {
    private final String id;
    private final Patient patient;
    private final LocalDateTime dateTime;
    private final List<MedicalService> services;
    private AppointmentStatus status;

    public Appointment(String id, Patient patient, LocalDateTime dateTime, List<MedicalService> services) {
        this.id = id;
        this.patient = patient;
        this.dateTime = dateTime;
        // Defensive copy prevents external modification
        this.services = List.copyOf(services);
        this.status = AppointmentStatus.SCHEDULED;
    }

    public String getId() { return id; }
    public Patient getPatient() { return patient; }
    public LocalDateTime getDateTime() { return dateTime; }
    public List<MedicalService> getServices() { return services; }
    public AppointmentStatus getStatus() { return status; }
}

Why is Appointment a class instead of a record?

Records are immutable. An appointment's status changes from SCHEDULED to COMPLETED or CANCELLED during its lifecycle. A plain class lets you mutate that single field while keeping everything else final.

  • Add the behavior methods below the getters, before the closing brace of the class:
    // Transition to cancelled state, with late flag determining the penalty tier
    public void cancel(boolean isLate) {
        this.status = isLate ? AppointmentStatus.LATE_CANCELLED : AppointmentStatus.CANCELLED;
    }

    public void complete() {
        this.status = AppointmentStatus.COMPLETED;
    }

    @Override
    public String toString() {
        return String.format("[%s] %s - %s | %s | Services: %d",
                id, patient.name(), dateTime, status.getDisplayName(), services.size());
    }

You also need a custom exception for booking conflicts. This lives in a separate exception package.

  • Create DoubleBookingException.java inside src/com/clinic/exception/ with this code:
package com.clinic.exception;

// Checked exception forces callers to handle scheduling conflicts
public class DoubleBookingException extends Exception {
    public DoubleBookingException(String message) {
        super(message);
    }
}

Why a checked exception?

Extending Exception (not RuntimeException) makes this a checked exception. The compiler forces every caller of the scheduling method to handle it with a try/catch or throws declaration. That guarantees no booking conflict ever goes unhandled.

  • Save Appointment.java and DoubleBookingException.java.
  • Compile the full project again by running:
javac -d out $(find src -name "*.java")

The compiler should exit with no errors. You now have four new model/exception files compiling alongside your existing patient types.

Getting a 'cannot find symbol' error?

Check that Appointment.java imports java.time.LocalDateTime and java.util.List. Also verify that DoubleBookingException.java uses the package com.clinic.exception matching its folder path.

Help me fix my Appointment compilation error.

✔️ Awesome, I've got everything!

Great. Confirm you have saved all four new files before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm your Appointment.java has these sections in order:

  • Package declaration and imports (LocalDateTime, List).
  • Five fields (id, patient, dateTime, services, status).
  • Constructor with defensive copy and SCHEDULED default.
  • Five one-line getters.
  • cancel(boolean isLate), complete(), and toString() methods.
Build the appointment service with conflict detection

The service layer stores appointments and enforces a business rule: the same patient cannot be booked at the same date and time twice. You will use streams to detect conflicts.

  • Create AppointmentService.java inside src/com/clinic/service/ with the class declaration and scheduling method:
package com.clinic.service;

import com.clinic.exception.DoubleBookingException;
import com.clinic.model.Appointment;
import com.clinic.model.AppointmentStatus;
import com.clinic.model.MedicalService;
import com.clinic.model.Patient;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

public class AppointmentService {
    private final List<Appointment> appointments = new ArrayList<>();
    private int nextId = 1;

    // Stream-based conflict detection before booking
    public Appointment scheduleAppointment(Patient patient, LocalDateTime dateTime, List<MedicalService> services)
            throws DoubleBookingException {
        boolean conflict = appointments.stream()
                .filter(a -> a.getStatus() == AppointmentStatus.SCHEDULED)
                .anyMatch(a -> a.getPatient().id().equals(patient.id()) && a.getDateTime().equals(dateTime));
        if (conflict) {
            throw new DoubleBookingException("Patient " + patient.name() + " already has an appointment at " + dateTime);
        }
        var appointment = new Appointment("APT-" + nextId++, patient, dateTime, services);
        appointments.add(appointment);
        return appointment;
    }
}

How does the conflict check work?

The stream filters the appointment list down to only SCHEDULED ones (ignoring cancelled appointments). Then anyMatch returns true if any existing appointment has the same patient ID AND the same date/time.

The ID generator ("APT-" + nextId++) produces sequential IDs like APT-1, APT-2, APT-3 without needing a database.

  • Add the remaining utility methods below scheduleAppointment, before the closing brace of the class:
    public Optional<Appointment> findById(String id) {
        return appointments.stream().filter(a -> a.getId().equals(id)).findFirst();
    }

    // Returns an unmodifiable copy of all appointments
    public List<Appointment> getAllAppointments() { return List.copyOf(appointments); }

    public void cancelAppointment(String id, boolean isLate) {
        findById(id).ifPresent(a -> a.cancel(isLate));
    }

    public void completeAppointment(String id) {
        findById(id).ifPresent(Appointment::complete);
    }

What does Optional buy you here?

findById returns Optional<Appointment> instead of a raw null. Callers use ifPresent to safely act on the result. If the ID does not match any appointment, nothing happens. No null checks needed.

  • Save AppointmentService.java.
  • Compile the project to confirm the service layer compiles cleanly:
javac -d out $(find src -name "*.java")

No errors means your service, model, and exception layers all link together correctly.

Seeing 'unreported exception DoubleBookingException'?

This means a caller of scheduleAppointment is not handling the checked exception. At this point only AppointmentService declares the method, so this error likely means the throws DoubleBookingException clause is missing from the method signature.

Help me fix my AppointmentService compilation error.

✔️ Awesome, I've got everything!

Great. Your appointment service is ready to be wired into the UI.

ⓧ I'd like to double check the full code

Scroll up and confirm your AppointmentService.java has these sections in order:

  • Package declaration and all imports (DoubleBookingException, model types, java.time, java.util).
  • Two fields: appointments ArrayList and nextId counter.
  • scheduleAppointment method with stream-based conflict check and DoubleBookingException throw.
  • findById, getAllAppointments, cancelAppointment, and completeAppointment utility methods.
Wire scheduling into the console menu

The model and service layers are ready. Now you need to connect them to the interactive menu so the user can schedule appointments and see the list.

  • In ConsoleUI.java, find the existing imports at the top of the file:
import com.clinic.model.*;
import com.clinic.service.PatientService;

import java.util.Scanner;
  • Replace those imports with the expanded set:
import com.clinic.exception.DoubleBookingException;
import com.clinic.model.*;
import com.clinic.service.AppointmentService;
import com.clinic.service.PatientService;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Scanner;
  • Find the two field declarations at the top of the class:
    private final Scanner scanner = new Scanner(System.in);
    private final PatientService patientService = new PatientService();
  • Add two new fields below them:
    private final AppointmentService appointmentService = new AppointmentService();
    private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
  • Find the switch expression inside the start() method:
            switch (choice) {
                case "1" -> addPatient();
                case "2" -> listPatients();
                case "0" -> { running = false; System.out.println("Goodbye!"); }
                default -> System.out.println("Invalid option. Try again.");
            }
  • Replace it with this version that adds cases "3" and "4":
            switch (choice) {
                case "1" -> addPatient();
                case "2" -> listPatients();
                case "3" -> scheduleAppointment();
                case "4" -> listAppointments();
                case "0" -> { running = false; System.out.println("Goodbye!"); }
                default -> System.out.println("Invalid option. Try again.");
            }
  • Find the printMainMenu() method and replace it with this updated version:
    private void printMainMenu() {
        System.out.print("""
                --- Main Menu ---
                1. Add Patient
                2. List Patients
                3. Schedule Appointment
                4. List Appointments
                0. Exit
                Choose an option: """);
    }
  • Save the file and compile to confirm the structural changes are correct:
javac -d out $(find src -name "*.java")

You will see errors about missing methods scheduleAppointment() and listAppointments(). That is expected. You will add them next.

  • Add the scheduleAppointment() method below listPatients() in ConsoleUI.java:
    private void scheduleAppointment() {
        System.out.print("Patient ID: ");
        String patientId = scanner.nextLine().trim();
        var patientOpt = patientService.findById(patientId);
        if (patientOpt.isEmpty()) { System.out.println("Patient not found."); return; }
        System.out.print("Date/Time (yyyy-MM-dd HH:mm): ");
        String dateInput = scanner.nextLine().trim();
        LocalDateTime dateTime;
        try { dateTime = LocalDateTime.parse(dateInput, formatter); }
        catch (DateTimeParseException e) { System.out.println("Invalid date format."); return; }
        // Loop to add multiple services to one appointment
        var services = new ArrayList<MedicalService>();
        boolean addingServices = true;
        while (addingServices) {
            System.out.print("Add service (name, 0=Done): ");
            String svcName = scanner.nextLine().trim();
            if (svcName.equals("0")) { addingServices = false; continue; }
            System.out.print("Cost: ");
            double cost;
            try { cost = Double.parseDouble(scanner.nextLine().trim()); }
            catch (NumberFormatException e) { cost = 0.0; }
            services.add(new MedicalService(svcName, cost));
        }
        if (services.isEmpty()) { System.out.println("No services added. Appointment not created."); return; }
        try {
            var appointment = appointmentService.scheduleAppointment(patientOpt.get(), dateTime, services);
            System.out.println("Appointment scheduled: " + appointment);
        } catch (DoubleBookingException e) { System.out.println("BOOKING ERROR: " + e.getMessage()); }
    }
  • Add the listAppointments() method below scheduleAppointment():
    private void listAppointments() {
        var appointments = appointmentService.getAllAppointments();
        if (appointments.isEmpty()) { System.out.println("No appointments."); return; }
        System.out.println("\n--- Appointments ---");
        appointments.forEach(System.out::println);
    }

What does the scheduling flow do?

It looks up the patient by ID, parses a date/time string using DateTimeFormatter, then loops to collect services. Each service is just a name and a cost (the flat model).

If the underlying service throws DoubleBookingException, the catch block prints a clear error prefixed with BOOKING ERROR: so the user knows the booking was rejected.

  • Save ConsoleUI.java.
  • Compile the full project:
javac -d out $(find src -name "*.java")

No errors this time. The full system compiles.

Still seeing compilation errors in ConsoleUI?

Verify that the import for com.clinic.exception.DoubleBookingException is present at the top of the file. Also check that java.util.ArrayList is imported. The new ArrayList<MedicalService>() inside scheduleAppointment requires it.

Help me fix my ConsoleUI compilation errors.

Now test the double-booking protection. Before you run the app, think about what should happen if you schedule the same patient at the same time twice. Will it silently overwrite, or will the stream-based check catch it?

  • Run the application:
java -cp out com.clinic.Main
  • Select option 1 to add a patient. Enter ID P001, name Alice, phone 555-1234, and tier GOLD.
  • Select option 3 to schedule an appointment. Enter patient ID P001, date/time 2026-08-15 09:00.
  • Add a service named Checkup with cost 150. Then enter 0 to finish adding services.
  • Select option 3 again. Enter the same patient ID P001 and the same date/time 2026-08-15 09:00.

You should see the error message:

BOOKING ERROR: Patient Alice already has an appointment at 2026-08-15T09:00

The stream found a conflict and the checked exception prevented the duplicate booking. Press 0 to exit.

Notice the flat billing model

Every service you added was stored as a simple name + cost record. A checkup at $150, a lab test at $80, and a procedure at $200 all bill at exactly that flat price. There is no processing fee, no time-based surcharge, no type-specific logic.

In a real clinic, lab tests cost more because of processing, and procedures bill per minute. The next step fixes this limitation with sealed interfaces and pattern matching.

Not seeing the BOOKING ERROR message?

Make sure you entered the exact same date/time both times: 2026-08-15 09:00. Even a one-minute difference makes the stream check pass because equals() compares the full LocalDateTime.

Also confirm you used the same patient ID (P001) both times. A different ID means a different patient, which is allowed at the same time.

Help me debug why my double-booking check isn't triggering.

✔️ Awesome, I've got everything!

Great. Make sure all files are saved before continuing.

ⓧ I'd like to double check the full code

Your ConsoleUI.java should contain these sections in order:

  • Imports for DoubleBookingException, model.*, AppointmentService, PatientService, LocalDateTime, DateTimeFormatter, DateTimeParseException, ArrayList, Scanner.
  • Four fields: scanner, patientService, appointmentService, formatter.
  • start() method with a switch containing cases 1 through 4 plus 0.
  • printMainMenu() showing 5 options (Add Patient, List Patients, Schedule Appointment, List Appointments, Exit).
  • addPatient() and listPatients() (unchanged from before).
  • scheduleAppointment() with patient lookup, date parsing, service loop, and DoubleBookingException catch.
  • listAppointments() printing all appointments.

Your clinic system now schedules appointments and rejects double-bookings. Next up, you will tackle the flat billing problem. Every service currently charges at face value, but a real clinic charges differently for consultations, lab tests, and procedures. You will fix that with sealed interfaces and pattern matching.

Introduce Sealed Service Types and Smart Billing

In the previous step, you scheduled appointments with flat services. Every service billed at face value regardless of type.

A real clinic charges differently for a consultation vs. a lab test (which incurs a processing fee) vs. a procedure (which carries a time-based surcharge). In this step, you will refactor MedicalService from a plain record into a sealed interface with three permitted record types, then build a billing engine that uses pattern matching for instanceof to calculate type-specific costs.

In this step, get ready to:
  • Refactor MedicalService into a sealed interface with three record subtypes.
  • Build a billing engine using pattern matching for instanceof.
  • Add a complete-and-bill menu option to see itemized type-specific costs.
Refactor MedicalService into a sealed interface

A sealed interface declares exactly which types are allowed to implement it. The compiler can verify you have handled every permitted type when you branch on them later.

  • Open src/com/clinic/model/MedicalService.java and replace its entire contents with the sealed interface below:
package com.clinic.model;

// Sealed interface: only Consultation, LabTest, and Procedure can implement this
public sealed interface MedicalService permits Consultation, LabTest, Procedure {
    String name();
    double baseCost();
}

What does sealed mean here?

The sealed keyword restricts which classes can implement this interface. Only the types listed after permits are allowed. This gives you compile-time safety: if you add a new service type later, the compiler will tell you everywhere that needs updating.

The two methods name() and baseCost() are abstract. Every permitted record must provide them through its fields.

The sealed interface won't compile until its permitted types exist. Create all three now.

  • Create a new file src/com/clinic/model/Consultation.java with this content:
package com.clinic.model;

// A consultation adds a speciality field (e.g. "Cardiology")
public record Consultation(String name, double baseCost, String speciality) implements MedicalService {
}
  • Create src/com/clinic/model/LabTest.java with this content:
package com.clinic.model;

// A lab test adds a sample type field (e.g. "Blood")
public record LabTest(String name, double baseCost, String sampleType) implements MedicalService {
}
  • Create src/com/clinic/model/Procedure.java with this content:
package com.clinic.model;

// A procedure adds a duration field used to calculate time-based surcharges
public record Procedure(String name, double baseCost, int durationMinutes) implements MedicalService {
}

Why records for the permitted types?

Records are immutable data carriers that automatically generate equals(), hashCode(), and toString(). They cannot extend a class (they implicitly extend java.lang.Record), but they can implement interfaces. That is why MedicalService must be a sealed interface, not an abstract class.

Now update the scheduling flow in ConsoleUI.java to use the new service types instead of the old flat record.

  • In src/com/clinic/ui/ConsoleUI.java, find the scheduleAppointment method's service-adding while loop. Find this code:
        while (addingServices) {
            System.out.print("Add service (name, 0=Done): ");
            String svcName = scanner.nextLine().trim();
            if (svcName.equals("0")) { addingServices = false; continue; }
            System.out.print("Cost: ");
            double cost;
            try { cost = Double.parseDouble(scanner.nextLine().trim()); }
            catch (NumberFormatException e) { cost = 0.0; }
            services.add(new MedicalService(svcName, cost));
        }
  • Replace that entire while loop with the following version that prompts for service type:
        while (addingServices) {
            System.out.print("Add service (1=Consultation, 2=Lab Test, 3=Procedure, 0=Done): ");
            String serviceChoice = scanner.nextLine().trim();
            switch (serviceChoice) {
                case "1" -> { System.out.print("Speciality: "); String spec = scanner.nextLine().trim(); System.out.print("Cost: "); double cost = parseDouble(); services.add(new Consultation("Consultation", cost, spec)); }
                case "2" -> { System.out.print("Test name: "); String testName = scanner.nextLine().trim(); System.out.print("Sample type: "); String sample = scanner.nextLine().trim(); System.out.print("Cost: "); double cost = parseDouble(); services.add(new LabTest(testName, cost, sample)); }
                case "3" -> { System.out.print("Procedure name: "); String procName = scanner.nextLine().trim(); System.out.print("Duration (minutes): "); int duration = parseInt(); System.out.print("Base cost: "); double cost = parseDouble(); services.add(new Procedure(procName, cost, duration)); }
                case "0" -> addingServices = false;
                default -> System.out.println("Invalid option.");
            }
        }
  • Add these two helper methods at the bottom of the ConsoleUI class, before the closing }:
    private double parseDouble() {
        try { return Double.parseDouble(scanner.nextLine().trim()); }
        catch (NumberFormatException e) { System.out.println("Invalid number. Using 0.0"); return 0.0; }
    }

    private int parseInt() {
        try { return Integer.parseInt(scanner.nextLine().trim()); }
        catch (NumberFormatException e) { System.out.println("Invalid number. Using 0"); return 0; }
    }

What do these helpers do?

The parseDouble() and parseInt() methods wrap scanner.nextLine() with try/catch so a non-numeric input won't crash the program. They default to 0 on failure and print a warning.

  • Save all files and compile the project by running:
javac -d out $(find src -name "*.java")
  • You should see no errors. If you see a compilation error, check that all four model files (MedicalService.java, Consultation.java, LabTest.java, Procedure.java) are saved in src/com/clinic/model/.

Seeing a compilation error?

If the compiler reports that Consultation, LabTest, or Procedure cannot be found, make sure each file is in src/com/clinic/model/ and that the class name matches the filename exactly (case-sensitive).

If you see an error about new MedicalService(...) in ConsoleUI, you haven't yet replaced the old while loop with the new type-specific version.

Help me fix a sealed interface compilation error in my Java clinic project.

Create the billing engine with pattern matching

The billing engine will inspect each service's actual type and compute cost accordingly. Pattern matching for instanceof lets you test the type and extract a typed variable in one expression.

  • Create a new file src/com/clinic/service/BillingService.java with the class shell and the calculateServiceCost method:
package com.clinic.service;

import com.clinic.model.*;

import java.util.ArrayList;
import java.util.List;

public class BillingService {

    // Pattern matching for instanceof: test type + extract variable in one step
    public double calculateServiceCost(MedicalService service) {
        if (service instanceof Consultation c) {
            return c.baseCost();
        } else if (service instanceof LabTest l) {
            return l.baseCost() * 1.15;
        } else if (service instanceof Procedure p) {
            return p.baseCost() + (p.durationMinutes() * 2.0);
        }
        throw new IllegalArgumentException("Unknown service type");
    }
}

What is pattern matching for instanceof?

Traditionally, you would cast after an instanceof check: if (service instanceof LabTest) { LabTest l = (LabTest) service; ... }. Pattern matching combines both into one line: if (service instanceof LabTest l). The variable l is already the correct type inside the block.

This is a permanent language feature since Java 16 (JEP 394) and is tested on the 1Z0-829 certification exam.

How does each service type calculate cost?

  • Consultation: billed at base cost with no surcharge.
  • LabTest: base cost multiplied by 1.15 (a 15% processing fee).
  • Procedure: base cost plus $2.00 per minute of duration.
  • Add the generateSubtotal and formatServiceDetail methods below calculateServiceCost, inside the BillingService class:
    // Compute total cost across all services in an appointment
    public double generateSubtotal(Appointment appointment) {
        return appointment.getServices().stream()
                .mapToDouble(this::calculateServiceCost)
                .sum();
    }

    // Format a single service line for invoice display
    private String formatServiceDetail(MedicalService service, double cost) {
        if (service instanceof Consultation c) {
            return String.format("  Consultation (%s): $%.2f", c.speciality(), cost);
        } else if (service instanceof LabTest l) {
            return String.format("  Lab Test - %s (%s): $%.2f", l.name(), l.sampleType(), cost);
        } else if (service instanceof Procedure p) {
            return String.format("  Procedure - %s (%d min): $%.2f", p.name(), p.durationMinutes(), cost);
        }
        return String.format("  %s: $%.2f", service.name(), cost);
    }

What does generateSubtotal do?

It uses a stream to map each service to its calculated cost via a method reference (this::calculateServiceCost), then sums the results. The formatServiceDetail method is private for now. You will use it in the next step when you build full invoices.

  • You also need to add an import for Appointment at the top of BillingService.java. The wildcard import com.clinic.model.* already covers it.
  • Save BillingService.java and compile again:
javac -d out $(find src -name "*.java")
  • You should see zero errors. The billing engine compiles but is not wired into the menu yet.

Compilation error in BillingService?

If you see an error about Appointment not being found, make sure your import line reads import com.clinic.model.*; (wildcard covers all model classes).

Help me fix my BillingService compilation error.

✔️ Awesome, I've got everything!

Great. Make sure BillingService.java is saved with both methods before moving on.

ⓧ I'd like to double check the full code

Here is the complete BillingService.java at this point:

package com.clinic.service;

import com.clinic.model.*;

import java.util.ArrayList;
import java.util.List;

public class BillingService {

    public double calculateServiceCost(MedicalService service) {
        if (service instanceof Consultation c) {
            return c.baseCost();
        } else if (service instanceof LabTest l) {
            return l.baseCost() * 1.15;
        } else if (service instanceof Procedure p) {
            return p.baseCost() + (p.durationMinutes() * 2.0);
        }
        throw new IllegalArgumentException("Unknown service type");
    }

    public double generateSubtotal(Appointment appointment) {
        return appointment.getServices().stream()
                .mapToDouble(this::calculateServiceCost)
                .sum();
    }

    private String formatServiceDetail(MedicalService service, double cost) {
        if (service instanceof Consultation c) {
            return String.format("  Consultation (%s): $%.2f", c.speciality(), cost);
        } else if (service instanceof LabTest l) {
            return String.format("  Lab Test - %s (%s): $%.2f", l.name(), l.sampleType(), cost);
        } else if (service instanceof Procedure p) {
            return String.format("  Procedure - %s (%d min): $%.2f", p.name(), p.durationMinutes(), cost);
        }
        return String.format("  %s: $%.2f", service.name(), cost);
    }
}
Wire up invoicing and verify type-specific billing

The billing engine exists but the user has no way to trigger it. You need a new menu option that completes an appointment and prints its itemized costs.

  • In ConsoleUI.java, add this import near the top with the other service imports:
import com.clinic.service.BillingService;
  • Find the line that declares appointmentService. Add this field directly below it:
    private final BillingService billingService = new BillingService();
  • In the start() method's switch expression, find the line case "4" -> listAppointments(); and add this case directly below it:
                case "5" -> completeAndBill();
  • In the printMainMenu method, add option 5 between the 4. List Appointments line and 0. Exit. The full menu text block should now read:
    private void printMainMenu() {
        System.out.print("""
                --- Main Menu ---
                1. Add Patient
                2. List Patients
                3. Schedule Appointment
                4. List Appointments
                5. Complete Appointment & Generate Invoice
                0. Exit
                Choose an option: """);
    }
  • Add the completeAndBill method below the listAppointments method:
    private void completeAndBill() {
        System.out.print("Appointment ID: "); String id = scanner.nextLine().trim();
        var aptOpt = appointmentService.findById(id);
        if (aptOpt.isEmpty()) { System.out.println("Appointment not found."); return; }
        var appointment = aptOpt.get();
        appointmentService.completeAppointment(id);
        // Calculate and display itemized costs
        double subtotal = billingService.generateSubtotal(appointment);
        System.out.printf("Subtotal: $%.2f%n", subtotal);
        appointment.getServices().forEach(s -> System.out.printf("  %s: $%.2f%n", s.name(), billingService.calculateServiceCost(s)));
    }

What does completeAndBill do?

It looks up the appointment by ID, marks it as completed, then prints each service's name alongside its type-specific calculated cost. Consultations, Lab Tests, and Procedures each produce different dollar amounts even if the base cost were the same.

  • Save ConsoleUI.java and compile:
javac -d out $(find src -name "*.java")

Zero errors means you are ready to test the full flow.

Seeing an error about completeAndBill or BillingService?

Make sure the import com.clinic.service.BillingService; line is at the top of ConsoleUI. Also verify you declared billingService as a field, not a local variable inside a method.

Help me fix the ConsoleUI compilation error after adding billingService.

Before you run the app, think about this: if you schedule a Consultation at $100, a Lab Test at $80, and a Procedure at $200 for 45 minutes, what do you expect the subtotal to be?

  • Run the app:
java -cp out com.clinic.Main
  • Add a patient with ID P001, name Alice, any phone number, and GOLD insurance.
  • Schedule an appointment for P001 at 2026-08-15 09:00.
  • Add a Consultation with speciality Cardiology and cost 100.
  • Add a Lab Test with name Blood Panel, sample type Blood, and cost 80.
  • Add a Procedure with name Stress Test, duration 45 minutes, and base cost 200.
  • Enter 0 to finish adding services.
  • Select option 5 (Complete Appointment & Generate Invoice) and enter APT-1.

You should see itemized output showing each type charged differently:

Subtotal: $482.00
  Consultation: $100.00
  Blood Panel: $92.00
  Stress Test: $290.00

Where do those numbers come from?

  • Consultation: $100 base cost, no surcharge = $100.00.
  • Lab Test: $80 x 1.15 (15% processing fee) = $92.00.
  • Procedure: $200 + (45 minutes x $2.00) = $290.00.
  • Subtotal: $100 + $92 + $290 = $482.00.

Each service type now calculates cost differently thanks to pattern matching for instanceof branching on the sealed interface's permitted types.

Seeing the wrong numbers or a crash?

Double-check that calculateServiceCost multiplies the lab test by 1.15 (not 0.15) and that the procedure formula is baseCost + (durationMinutes * 2.0).

Help me debug my billing calculations.

✔️ Awesome, I've got everything!

Make sure all files are saved. Your billing engine is computing type-specific costs correctly.

ⓧ I'd like to double check the full code

Your project now spans many files. Scroll up and confirm each chunk is in place:

  • src/com/clinic/model/MedicalService.java is a sealed interface (not a record) with permits Consultation, LabTest, Procedure.
  • src/com/clinic/model/Consultation.java is a record with fields name, baseCost, speciality.
  • src/com/clinic/model/LabTest.java is a record with fields name, baseCost, sampleType.
  • src/com/clinic/model/Procedure.java is a record with fields name, baseCost, durationMinutes.
  • src/com/clinic/service/BillingService.java has calculateServiceCost, generateSubtotal, and formatServiceDetail.
  • src/com/clinic/ui/ConsoleUI.java imports BillingService, has a billingService field, option 5 in the menu and switch, the new scheduling loop with type prompts, completeAndBill, parseDouble, and parseInt methods.

Your billing engine now treats each service type differently using sealed interfaces and pattern matching. Next, you will add insurance discounts, copays, and late-cancellation fees to produce a complete invoice.

Add Insurance, Copay, and Late Cancellation to Invoices

Your billing engine computes per-service costs correctly, but it ignores a critical piece of real-world billing: insurance. A GOLD patient paying full price for a Consultation makes no sense when they have 80% coverage.

In this step, you will complete the billing model by subtracting the insurance coverage percentage, adding a fixed copay, and penalizing late cancellations. The InsuranceTier enum you built earlier already holds the data. Now you will put it to work.

In this step, get ready to:
  • Create an Invoice record that bundles all billing data into one immutable object.
  • Update BillingService to compute insurance discounts, copays, and late-cancellation fees.
  • Add a cancel option and a formatted invoice printout to the console UI.
Create the Invoice record and update the billing engine

The invoice needs to carry all billing details in one place: line items, subtotal, discount, copay, late fee, and final total. A record is the perfect fit because an invoice should be immutable once generated.

  • Create a new file Invoice.java inside src/com/clinic/model/ with the following code:
package com.clinic.model;

import java.util.List;

// Immutable billing summary for a completed appointment
public record Invoice(
        String appointmentId,
        String patientName,
        List<String> serviceDetails,
        double subtotal,
        double insuranceDiscount,
        double copay,
        double lateCancellationFee,
        double totalDue
) {}

What does this code do?

This record holds the complete billing breakdown for one appointment. The compiler auto-generates the constructor, getters, equals, hashCode, and toString from the field list.

Using a record here means once an invoice is created, its values cannot change. That matches real-world billing: you generate, print, and archive an invoice without editing it after the fact.

Now you need a method that creates an Invoice by computing the insurance math. The existing generateSubtotal method only sums service costs. You will replace it with a full generateInvoice method.

  • In BillingService.java, find the generateSubtotal method and replace it with the following generateInvoice method:
public Invoice generateInvoice(Appointment appointment) {
    var serviceDetails = new ArrayList<String>();
    double subtotal = 0.0;
    // Build line items and running subtotal
    for (MedicalService service : appointment.getServices()) {
        double cost = calculateServiceCost(service);
        subtotal += cost;
        serviceDetails.add(formatServiceDetail(service, cost));
    }
    // Pull insurance data from the patient's tier
    var tier = appointment.getPatient().insuranceTier();
    double insuranceDiscount = subtotal * tier.getCoveragePercent() / 100.0;
    double copay = tier.getCopay();
    // Apply 25% late-cancellation penalty if applicable
    double lateFee = 0.0;
    if (appointment.getStatus() == AppointmentStatus.LATE_CANCELLED) {
        lateFee = subtotal * 0.25;
    }
    double totalDue = Math.max(0, subtotal - insuranceDiscount + copay + lateFee);
    return new Invoice(appointment.getId(), appointment.getPatient().name(),
            List.copyOf(serviceDetails), subtotal, insuranceDiscount, copay, lateFee, totalDue);
}

What does this code do?

The method iterates through services, computes per-service costs using calculateServiceCost (pattern matching), and builds formatted line items using formatServiceDetail.

Then it reads the patient's InsuranceTier to compute the discount and copay. If the appointment was late-cancelled, a 25% penalty applies to the subtotal. Finally, Math.max(0, ...) ensures the total never goes negative.

  • Also delete the old generateSubtotal method entirely. It is no longer needed.
  • Save Invoice.java and BillingService.java.

Before you think this will compile cleanly, remember: ConsoleUI.java still calls generateSubtotal. Let's confirm that by compiling now.

  • Compile the project by running:
javac -d out $(find src -name "*.java")

You will see a cannot find symbol error pointing at ConsoleUI.java where it references generateSubtotal. That confirms the old method is gone and the refactor is connected.

Why is this error helpful?

The compiler is protecting you. Because you removed generateSubtotal, any code still referencing it will not compile. This is the same compile-time safety that sealed interfaces give you. No broken path can slip through silently.

Wire the invoice and cancellation into the UI

The billing engine is ready, but the UI still uses the old method. You need to add a formatted invoice printout, a cancel option (so you can test late-cancellation fees), and update the existing complete-and-bill flow.

  • In ConsoleUI.java, add these two new private methods below the existing listAppointments method:
private void cancelAppointment() {
    System.out.print("Appointment ID: ");
    String id = scanner.nextLine().trim();
    var aptOpt = appointmentService.findById(id);
    if (aptOpt.isEmpty()) { System.out.println("Appointment not found."); return; }
    System.out.print("Is this a late cancellation (< 24 hours)? (yes/no): ");
    boolean isLate = scanner.nextLine().trim().toLowerCase().equals("yes");
    appointmentService.cancelAppointment(id, isLate);
    System.out.println("Appointment cancelled." + (isLate ? " Late cancellation fee will apply." : ""));
}

What does this code do?

This method asks for an appointment ID, then asks whether the cancellation is late (less than 24 hours before the appointment). If the learner types yes, the appointment status changes to LATE_CANCELLED, which triggers the 25% penalty when you generate the invoice later.

  • Below cancelAppointment, add the printInvoice method that formats the full invoice using a text block:
private void printInvoice(Invoice invoice) {
    // Text block formats the complete invoice with all billing lines
    String output = """
            \n            ==============================
                   INVOICE
            ==============================
            Appointment: %s
            Patient: %s
            \n            Services:
            %s
            \n            Subtotal:             $%.2f
            Insurance Discount:  -$%.2f
            Copay:               +$%.2f
            Late Cancel Fee:     +$%.2f
            ------------------------------
            TOTAL DUE:            $%.2f
            ==============================
            """.formatted(
            invoice.appointmentId(), invoice.patientName(),
            String.join("\n", invoice.serviceDetails()),
            invoice.subtotal(), invoice.insuranceDiscount(),
            invoice.copay(), invoice.lateCancellationFee(), invoice.totalDue());
    System.out.println(output);
}

What is a text block?

A text block ("""...""") is a Java 15+ feature that lets you write multi-line strings without concatenation or escape sequences for newlines. The .formatted() call at the end works like String.format() but is called directly on the string instance.

The \s at the end of lines in text blocks preserves trailing whitespace. The %s and %.2f placeholders get filled by .formatted() with actual invoice values.

Now update the existing completeAndBill method to use the new invoice system instead of the old subtotal printout.

  • In ConsoleUI.java, find the completeAndBill method. Locate these lines at the end of it:
        double subtotal = billingService.generateSubtotal(appointment);
        System.out.printf("Subtotal: $%.2f%n", subtotal);
        appointment.getServices().forEach(s -> System.out.printf("  %s: $%.2f%n", s.name(), billingService.calculateServiceCost(s)));
  • Replace those three lines with:
        var invoice = billingService.generateInvoice(appointment);
        printInvoice(invoice);

Finally, update the menu to include a cancel option and renumber the complete-and-bill option.

  • In the printMainMenu method, find this line:
                5. Complete Appointment & Generate Invoice
  • Replace it with these two lines:
                5. Cancel Appointment
                6. Complete Appointment & Generate Invoice
  • In the start method, find the switch expression inside the while loop. Replace:
                case "5" -> completeAndBill();
  • With these two cases:
                case "5" -> cancelAppointment();
                case "6" -> completeAndBill();
  • Save ConsoleUI.java.

Seeing a compile error about generateSubtotal?

Make sure you replaced all three lines in completeAndBill that referenced generateSubtotal. The method no longer exists in BillingService, so any leftover reference will cause a cannot find symbol error.

help me fix the cannot find symbol error in my ConsoleUI after refactoring BillingService

  • Compile and run the project:
javac -d out $(find src -name "*.java") && java -cp out com.clinic.Main

You should see === Medical Clinic System === followed by the updated menu with options 1 through 6 plus 0 to exit.

Test the full billing flow

Time to prove the insurance math works end-to-end. You will add a GOLD patient, schedule an appointment with three services, complete it, and verify the invoice numbers.

  • Select option 1 to add a patient. Enter ID P001, name Alice, phone 555-1234, insurance tier GOLD.
  • Select option 3 to schedule an appointment for patient P001 at 2026-08-15 09:00.
  • Add a Consultation (option 1) with speciality Cardiology and cost 100.
  • Add a Lab Test (option 2) with name CBC, sample type Blood, and cost 80.
  • Add a Procedure (option 3) with name ECG, duration 45 minutes, and base cost 200.
  • Enter 0 to finish adding services.

Before you complete this appointment, predict what the invoice will show. The subtotal should be $100 + $92 (80 x 1.15) + $290 (200 + 45x2) = $482.00. GOLD insurance covers 80%, so the discount is $385.60. Copay is $20.

  • Select option 6 to complete and bill. Enter appointment ID APT-1.

Your invoice should show:

  • Subtotal: $482.00.
  • Insurance Discount: -$385.60.
  • Copay: +$20.00.
  • Late Cancel Fee: +$0.00.
  • TOTAL DUE: $116.40.

Now test the late-cancellation path.

  • Schedule another appointment for P001 at a different time (e.g. 2026-08-16 10:00) with any single service.
  • Select option 5 to cancel it. Enter the appointment ID and type yes for late cancellation.
  • Select option 6 to generate the invoice for that cancelled appointment. You should see the 25% late fee applied to the subtotal.

Invoice showing $0.00 for everything?

Check that your generateInvoice method reads the patient's tier with appointment.getPatient().insuranceTier(). If you accidentally hardcoded InsuranceTier.NONE, all values will be zero.

Also confirm completeAndBill calls appointmentService.completeAppointment(id) before billingService.generateInvoice(appointment). The status must be set before the invoice reads it.

help me debug my invoice showing zero values

✔️ Awesome, I've got everything!

Great. Confirm you have saved both Invoice.java, BillingService.java, and ConsoleUI.java.

ⓧ I'd like to double check the full code

Here is the complete Invoice.java:

package com.clinic.model;

import java.util.List;

public record Invoice(
        String appointmentId,
        String patientName,
        List<String> serviceDetails,
        double subtotal,
        double insuranceDiscount,
        double copay,
        double lateCancellationFee,
        double totalDue
) {}

Here is the complete BillingService.java:

package com.clinic.service;

import com.clinic.model.*;

import java.util.ArrayList;
import java.util.List;

public class BillingService {

    public double calculateServiceCost(MedicalService service) {
        if (service instanceof Consultation c) return c.baseCost();
        else if (service instanceof LabTest l) return l.baseCost() * 1.15;
        else if (service instanceof Procedure p) return p.baseCost() + (p.durationMinutes() * 2.0);
        throw new IllegalArgumentException("Unknown service type");
    }

    public Invoice generateInvoice(Appointment appointment) {
        var serviceDetails = new ArrayList<String>();
        double subtotal = 0.0;
        for (MedicalService service : appointment.getServices()) {
            double cost = calculateServiceCost(service);
            subtotal += cost;
            serviceDetails.add(formatServiceDetail(service, cost));
        }
        var tier = appointment.getPatient().insuranceTier();
        double insuranceDiscount = subtotal * tier.getCoveragePercent() / 100.0;
        double copay = tier.getCopay();
        double lateFee = 0.0;
        if (appointment.getStatus() == AppointmentStatus.LATE_CANCELLED) lateFee = subtotal * 0.25;
        double totalDue = Math.max(0, subtotal - insuranceDiscount + copay + lateFee);
        return new Invoice(appointment.getId(), appointment.getPatient().name(), List.copyOf(serviceDetails), subtotal, insuranceDiscount, copay, lateFee, totalDue);
    }
}

For ConsoleUI.java, scroll up and confirm you have all of these in order:

  • The start method switch includes case "5" -> cancelAppointment() and case "6" -> completeAndBill().
  • The printMainMenu text block lists options 1-6 plus 0 for Exit.
  • The cancelAppointment method asks for late cancellation yes/no.
  • The completeAndBill method calls billingService.generateInvoice(appointment) and printInvoice(invoice).
  • The printInvoice method uses a text block with .formatted() to print the full invoice.

Your billing engine now handles insurance discounts, copays, and late-cancellation penalties. Every invoice you generate is an immutable record capturing a complete financial snapshot of the visit. That is the full billing pipeline, built entirely with Java 17 language features and zero frameworks.

Secret mission

Add Providers to the System

A real clinic assigns doctors to appointments. Can you add a Provider entity, compose it into the existing Appointment class, filter by provider using streams, and prevent provider schedule conflicts?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files for future reference or delete them entirely.

Resources you used:

  • The clinic-billing/ project folder on your Desktop (source code and compiled class files).

Keep everything running

No action needed. Choose this if you plan to keep extending the clinic system or want to reference the code later.

  • Your source files stay in ~/Desktop/clinic-billing/src/.
  • Your compiled class files stay in ~/Desktop/clinic-billing/out/.
  • JDK 17 remains installed on your system (it was already there before this project).

Pause - I'll come back to this later

Since this is a local project with no running processes, there is nothing to pause. Your files sit on disk and consume no memory or CPU when you are not actively running the program.

  • Optionally delete the compiled output to save a small amount of disk space by running:
rm -rf ~/Desktop/clinic-billing/out

You can recompile any time with the same javac command from Step 1.

Delete - I don't want to use this again

Remove the entire project folder from your Desktop. This deletes all source code and compiled files permanently.

  • On macOS or Linux, run:
rm -rf ~/Desktop/clinic-billing
  • On Windows, run:
rmdir /s /q %USERPROFILE%\Desktop\clinic-billing
  • Verify the folder is gone by running ls ~/Desktop (macOS/Linux) or checking your Desktop in File Explorer (Windows).

Nice Work!

Nice Work!

You just built a complete Medical Clinic Appointment and Billing System from scratch using pure Java 17. No frameworks, no dependencies, just clean language features wired into a layered architecture.

You've learned how to:

  • Design domain models with Java 17 features: records for immutable data, a sealed interface with permitted subtypes, and enums with fields and methods to encapsulate insurance tier behavior.
  • Build a billing engine that uses pattern matching for instanceof to branch on service type with compile-time safety, computing type-specific costs for Consultations, Lab Tests, and Procedures.
  • Wire a layered architecture (model/service/exception/ui) that prevents double-bookings with custom checked exceptions, computes insurance discounts with streams, and formats invoices with text blocks.
  • Secret Mission: Added a Provider entity with relationship composition, provider schedule conflict detection, and stream-based appointment filtering.

Ready to quiz yourself?