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.
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
// 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 → IntegerAutoboxing 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.
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:
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().
// 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:
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:
// 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
// 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):
// 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.
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:
// 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:
// 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)
// 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+)
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:
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?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.