Nizam Miftahul
← All articles

Java Fundamentals: Types, Classes, Records, Interfaces, Enums & Packages

JavaBackendLanguage Fundamentals

1. Data types

Java has two genuinely different categories: primitive and reference.

Primitive Size Default Wrapper
boolean 1 bit (JVM-dependent) false Boolean
byte 8 bit 0 Byte
short 16 bit 0 Short
char 16 bit (UTF-16) Character
int 32 bit 0 Integer
long 64 bit 0L Long
float 32 bit 0.0f Float
double 64 bit 0.0d Double

Primitives can never be null and are stored by value. Reference types (String, arrays, every class) store an object address and can be null.

Autoboxing and its traps:

Integer a = 127, b = 127;
System.out.println(a == b);   // true  -> Integer cache (-128..127)

Integer c = 128, d = 128;
System.out.println(c == d);   // false -> different objects
System.out.println(c.equals(d)); // true

Integer value = null;
int x = value;                 // NullPointerException during unboxing

Rule of thumb: use primitives when a value is always present, use wrappers only when null is meaningful (e.g. a nullable DB column). Always compare objects with equals(), never ==.

Never use double for money:

System.out.println(0.1 + 0.2);                    // 0.30000000000000004
new BigDecimal("0.1").add(new BigDecimal("0.2")); // 0.3

BigDecimal must be constructed from a String, not from a double literal.

var (Java 10+) is local type inference only — it’s still statically typed:

var items = new ArrayList<String>();  // ArrayList<String>
var total = 0L;                        // long
// var x;        -> error, no initializer
// var y = null; -> error

Text blocks (Java 15+):

String json = """
    {
      "id": 1,
      "name": "Budi"
    }
    """;

2. class vs record

record (Java 16+) is an immutable data carrier. The compiler generates the constructor, accessors, equals, hashCode, and toString automatically.

public record Money(BigDecimal amount, String currency) {

    // compact constructor: for validation & normalization
    public Money {
        Objects.requireNonNull(amount);
        Objects.requireNonNull(currency);
        if (amount.signum() < 0) {
            throw new IllegalArgumentException("amount must not be negative");
        }
        currency = currency.toUpperCase();
    }

    // extra methods are allowed
    public Money plus(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("currency mismatch");
        }
        return new Money(amount.add(other.amount), currency);
    }
}

Note: the accessor is money.amount(), not getAmount().

record class
Fields final, implicit free
Mutability immutable (shallow) free
equals/hashCode automatic, component-based written manually
Inheritance can’t extend another class can
Implements interface yes yes
Best for DTOs, value objects, API responses JPA entities, services, mutable state

Important boundary: record immutability is shallow. record Order(List<Item> items) can still be modified through its list. Fix it with a defensive copy in the compact constructor:

public record Order(String id, List<Item> items) {
    public Order {
        items = List.copyOf(items);   // immutable copy
    }
}

For a regular class, equals and hashCode must be consistent if instances go into a HashMap/HashSet:

public class Customer {
    private final String id;
    private String name;

    public Customer(String id, String name) {
        this.id = Objects.requireNonNull(id);
        this.name = name;
    }

    public String getId() { return id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Customer other)) return false;   // pattern matching
        return id.equals(other.id);
    }

    @Override
    public int hashCode() { return Objects.hash(id); }

    @Override
    public String toString() { return "Customer[id=%s, name=%s]".formatted(id, name); }
}

3. interface

A behavioral contract. Since Java 8+ it can contain implementations too.

public interface PaymentGateway {

    // abstract: must be implemented
    PaymentResult charge(Money amount, String cardToken);

    // default: built-in implementation, overridable
    default PaymentResult chargeIdr(BigDecimal amount, String cardToken) {
        return charge(new Money(amount, "IDR"), cardToken);
    }

    // static: utility owned by the interface
    static PaymentGateway noop() {
        return (amount, token) -> PaymentResult.success("noop");
    }

    // private: internal helper for default methods (Java 9+)
    private void audit(String message) {
        System.out.println("[audit] " + message);
    }
}

Every interface member is implicitly public. Fields are implicitly public static final, so don’t put mutable state there.

A class may implement multiple interfaces (there’s no multiple inheritance for classes). If two interfaces have a default method with the same signature, the compiler forces you to override and pick one:

public class Impl implements A, B {
    @Override
    public void run() { A.super.run(); }
}

A functional interface has exactly one abstract method, so it can be written as a lambda:

@FunctionalInterface
public interface Validator<T> {
    List<String> validate(T value);
}

Validator<String> notBlank = s ->
    s.isBlank() ? List.of("must not be blank") : List.of();

Already provided by the JDK in java.util.function: Function<T,R>, Supplier<T>, Consumer<T>, Predicate<T>, BiFunction<T,U,R>.

interface vs abstract class:

interface abstract class
Multiple inheritance yes no
Instance state/field no yes
Constructor no yes
Best for contracts, capabilities base class with shared state

Default: start with interface. Move up to abstract class only when you need shared state or a constructor.

4. enum

Not just constants — an enum is a full class with a fixed set of instances.

public enum OrderStatus {
    DRAFT("Draft"),
    PAID("Paid"),
    SHIPPED("Shipped"),
    CANCELLED("Cancelled");

    private final String label;

    OrderStatus(String label) {   // constructor is always private
        this.label = label;
    }

    public String label() { return label; }

    public boolean isFinal() {
        return this == SHIPPED || this == CANCELLED;
    }
}

Enums can implement interfaces, and each constant can have its own behavior:

public enum Operation implements BinaryOperator<BigDecimal> {
    ADD {
        @Override public BigDecimal apply(BigDecimal a, BigDecimal b) { return a.add(b); }
    },
    SUBTRACT {
        @Override public BigDecimal apply(BigDecimal a, BigDecimal b) { return a.subtract(b); }
    };
}

Built-in methods: values(), valueOf(String), name(), ordinal().

A few things worth guarding:

  • Don’t persist ordinal() to a database. The order shifts the moment a new constant is inserted. Store name() instead.
  • valueOf() throws IllegalArgumentException when nothing matches. Wrap it when the input comes from user input.
  • Use EnumMap and EnumSet instead of HashMap/HashSet — both are far more efficient for enums.
  • A switch over an enum can be exhaustive (Java 14+ arrow form), so the compiler helps you when a constant is added:
String message = switch (status) {
    case DRAFT -> "Not yet paid";
    case PAID -> "Awaiting shipment";
    case SHIPPED, CANCELLED -> "Done";
};

Enums are also the idiomatic way to build a singleton, since the JVM guarantees it (thread-safe, safe against double deserialization).

5. Packages

A namespace and an encapsulation unit at once. Convention: reverse domain, all lowercase, no underscores.

src/main/java/com/example/billing/domain/Invoice.java
                 ^-------------------^
                 package com.example.billing.domain;

The directory structure must exactly mirror the package name. A single .java file may only have one public type, and its name must match the file name.

Two organizational styles:

# by layer                    # by feature (preferred)
com.example.controller        com.example.billing.InvoiceController
com.example.service           com.example.billing.InvoiceService
com.example.repository        com.example.billing.InvoiceRepository
com.example.model             com.example.catalog.ProductController

Package-by-feature makes package-private actually useful: a feature’s internal classes don’t leak into other features.

Imports: import com.example.billing.Invoice; for a single class, import java.util.*; for everything (avoid it, it’s prone to name collisions). import static java.util.Objects.requireNonNull; for static members.

6. Access modifiers

Modifier Same class Same package Subclass (different package) Everyone
private ✅ ❌ ❌ ❌
(no modifier) ✅ ✅ ❌ ❌
protected ✅ ✅ ✅ ❌
public ✅ ✅ ✅ ✅

The no-modifier default is called package-private, and it has no real equivalent in many other languages. Use it to hide implementation details within a single package.

A top-level class can only be public or package-private.

A healthy default: start with private, loosen only when actually needed. Fields should always be private (except public static final constants). protected is part of your public API toward subclasses, so treat it with the same care as public.

Other modifiers that often come along:

public final class Config { }        // cannot be extended
private static final int MAX = 100;  // constant, UPPER_SNAKE_CASE
private final String id;             // must be set in the constructor, immutable