Back to blog
Backend Systemsbeginner

Java Fundamentals: Types, Generics, Streams & Modern Records

Master the Java fundamentals every backend developer needs — the type system, generics, lambdas, the Stream API, Optional, records, sealed classes, and the modern Java 17–21 features you'll use daily.

LearnixoApril 16, 20267 min read
JavaSpring BootBackendJVMJava 17Java 21StreamsGenerics
Share:𝕏

The Java Type System

Java is statically typed — every variable has a declared type known at compile time. This catches whole classes of bugs before runtime.

Primitives vs Objects

JAVA
// Primitives — stored directly, zero overhead
int     count   = 42;
double  price   = 9.99;
boolean active  = true;
char    grade   = 'A';

// Objects — stored as references on the heap
String  name    = "Jane";
Integer wrapped = 42;        // autoboxing: int → Integer

Autoboxing happens automatically when you assign a primitive to its wrapper type. Unboxing is the reverse. Be careful of NullPointerException when unboxing a null reference.

Strings

Strings in Java are immutable — every "modification" creates a new object.

JAVA
String s = "hello";
s.toUpperCase();      // does nothing — result discarded!
s = s.toUpperCase();  // correct

// String formatting (Java 15+)
String msg = "Patient %s has appointment on %s".formatted("Jane", "2026-04-16");

// Text blocks (Java 15+) — multiline without escaping
String json = """
        {
            "name": "Jane",
            "status": "active"
        }
        """;

For repeated concatenation inside loops, use StringBuilder:

JAVA
StringBuilder sb = new StringBuilder();
for (String tag : tags) {
    sb.append(tag).append(",");
}
String result = sb.toString();

Records (Java 16+)

Records are immutable data carriers — they auto-generate constructor, getters, equals(), hashCode(), and toString().

JAVA
// Old way — 30+ lines of boilerplate
public class PatientDTO {
    private final String id;
    private final String name;
    private final String email;
    // constructor, getters, equals, hashCode, toString...
}

// New way — one line
public record PatientDTO(String id, String name, String email) {}

// Usage
PatientDTO patient = new PatientDTO("PAT-001", "Jane Doe", "jane@example.com");
System.out.println(patient.name());   // getter via record component name
System.out.println(patient);          // PatientDTO[id=PAT-001, name=Jane Doe, email=jane@example.com]

Records support compact constructors for validation:

JAVA
public record AppointmentRequest(String clinicId, String patientId, LocalDateTime dateTime) {
    public AppointmentRequest {
        Objects.requireNonNull(clinicId,   "clinicId is required");
        Objects.requireNonNull(patientId,  "patientId is required");
        Objects.requireNonNull(dateTime,   "dateTime is required");
        if (dateTime.isBefore(LocalDateTime.now())) {
            throw new IllegalArgumentException("Appointment must be in the future");
        }
    }
}

Generics

Generics let you write type-safe code that works with any type:

JAVA
// Without generics — you lose type safety
List list = new ArrayList();
list.add("hello");
String s = (String) list.get(0); // cast required, can fail at runtime

// With generics — type checked at compile time
List<String> names = new ArrayList<>();
names.add("hello");
String name = names.get(0);  // no cast needed

// Generic method
public <T> List<T> paginate(List<T> items, int page, int pageSize) {
    int from = page * pageSize;
    int to   = Math.min(from + pageSize, items.size());
    return items.subList(from, to);
}

// Generic class
public class ApiResponse<T> {
    private final T data;
    private final int status;
    private final String message;

    public ApiResponse(T data, int status, String message) {
        this.data    = data;
        this.status  = status;
        this.message = message;
    }

    public static <T> ApiResponse<T> ok(T data) {
        return new ApiResponse<>(data, 200, "ok");
    }

    public static <T> ApiResponse<T> error(String message) {
        return new ApiResponse<>(null, 500, message);
    }
}

Bounded Type Parameters

JAVA
// T must extend Number
public <T extends Number> double sum(List<T> numbers) {
    return numbers.stream().mapToDouble(Number::doubleValue).sum();
}

// Wildcards — when you don't need to use T
public void printAll(List<?> items) {
    items.forEach(System.out::println);
}

// Upper bounded — read-only list of Number or subclass
public double total(List<? extends Number> numbers) { ... }

// Lower bounded — writable list of Number or superclass
public void addNumbers(List<? super Integer> list) { ... }

Lambdas & Functional Interfaces

A lambda is an anonymous function — assign it to a functional interface (an interface with exactly one abstract method):

JAVA
// Verbose anonymous class
Runnable r = new Runnable() {
    @Override
    public void run() { System.out.println("running"); }
};

// Lambda equivalent
Runnable r = () -> System.out.println("running");

// Common functional interfaces
Predicate<String>         isLong    = s -> s.length() > 10;
Function<String, Integer> getLength = String::length;        // method reference
Consumer<String>          printer   = System.out::println;
Supplier<List<String>>    newList   = ArrayList::new;
Comparator<Integer>       desc      = Comparator.reverseOrder();

The Stream API

Streams provide a declarative, functional-style pipeline for processing collections. They don't mutate the source — they produce a new result.

JAVA
List<Appointment> appointments = getAppointments();

// Filter → map → collect
List<String> upcomingPatientNames = appointments.stream()
    .filter(a -> a.status() == Status.SCHEDULED)
    .filter(a -> a.dateTime().isAfter(LocalDateTime.now()))
    .sorted(Comparator.comparing(Appointment::dateTime))
    .map(Appointment::patientName)
    .distinct()
    .collect(Collectors.toList());

// Group by clinic
Map<String, List<Appointment>> byClinic = appointments.stream()
    .collect(Collectors.groupingBy(Appointment::clinicId));

// Count by status
Map<Status, Long> countByStatus = appointments.stream()
    .collect(Collectors.groupingBy(Appointment::status, Collectors.counting()));

// Flat map — flatten nested lists
List<String> allTags = clinics.stream()
    .flatMap(clinic -> clinic.tags().stream())
    .distinct()
    .toList();  // Java 16+ immutable list

// Reduce — aggregate to a single value
OptionalDouble avgDuration = appointments.stream()
    .mapToLong(Appointment::durationMinutes)
    .average();

avgDuration.ifPresent(avg -> System.out.printf("Avg duration: %.1f min%n", avg));

Parallel Streams

For CPU-intensive work on large collections:

JAVA
// Use parallelStream() — automatically uses ForkJoinPool
List<String> processed = records.parallelStream()
    .map(this::expensiveTransform)
    .collect(Collectors.toList());

Warning: don't use parallel streams for I/O operations — use async/reactive for that.


Optional

Optional<T> is a container that may or may not hold a value — it makes nullability explicit and eliminates NPEs:

JAVA
// Avoid — return null forces callers to remember null-checks
public Patient findPatient(String id) {
    return db.find(id);  // could be null!
}

// Better — Optional communicates "may be absent"
public Optional<Patient> findPatient(String id) {
    return Optional.ofNullable(db.find(id));
}

// Idiomatic Optional usage
findPatient("PAT-001")
    .filter(p -> p.isActive())
    .map(Patient::email)
    .ifPresentOrElse(
        email -> sendReminder(email),
        ()    -> log.warn("Patient not found")
    );

// With default
String email = findPatient(id)
    .map(Patient::email)
    .orElse("noreply@clinic.com");

// Throw if absent
Patient patient = findPatient(id)
    .orElseThrow(() -> new PatientNotFoundException(id));

Pattern Matching (Java 16–21)

instanceof Pattern Matching (Java 16)

JAVA
// Old
if (obj instanceof String) {
    String s = (String) obj;
    System.out.println(s.length());
}

// New
if (obj instanceof String s) {
    System.out.println(s.length());
}

Switch Expressions (Java 14+)

JAVA
String label = switch (status) {
    case SCHEDULED  -> "Upcoming";
    case COMPLETED  -> "Done";
    case CANCELLED  -> "Cancelled";
    case NO_SHOW    -> "No Show";
};

// With guards (Java 21 pattern matching for switch)
String tier = switch (score) {
    case int s when s >= 90 -> "Gold";
    case int s when s >= 75 -> "Silver";
    default                 -> "Bronze";
};

Sealed Classes (Java 17)

Restrict which classes can extend a type — great for domain modelling:

JAVA
public sealed interface CallEvent
    permits CallStarted, CallEnded, CallTransferred {
    String callId();
    Instant occurredAt();
}

public record CallStarted(String callId, String queueId, Instant occurredAt) implements CallEvent {}
public record CallEnded(String callId, int durationSeconds, Instant occurredAt)  implements CallEvent {}
public record CallTransferred(String callId, String toAgent, Instant occurredAt) implements CallEvent {}

// Exhaustive switch — compiler errors if you miss a case
String describe(CallEvent event) {
    return switch (event) {
        case CallStarted   e -> "Call %s started in queue %s".formatted(e.callId(), e.queueId());
        case CallEnded     e -> "Call %s ended after %ds".formatted(e.callId(), e.durationSeconds());
        case CallTransferred e -> "Call %s transferred to %s".formatted(e.callId(), e.toAgent());
    };
}

Collections Cheat Sheet

| Need | Use | |------|-----| | Ordered, fast random access | ArrayList<T> | | Fast insert/delete at head/tail | LinkedList<T> (also a Deque) | | Unique items, insertion order | LinkedHashSet<T> | | Unique items, sorted | TreeSet<T> | | Key-value, insertion order | LinkedHashMap<K,V> | | Key-value, sorted by key | TreeMap<K,V> | | Thread-safe map | ConcurrentHashMap<K,V> | | Immutable list | List.of(a, b, c) | | Immutable map | Map.of("k1", v1, "k2", v2) |

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.