Steven's Knowledge

Java & Kotlin

Modern Java features, Spring Boot essentials, JVM internals, Kotlin advantages, and enterprise Java in New Zealand

Java & Kotlin

Java is not exciting. That is its strength. It is the language that runs the banking systems, insurance platforms, and enterprise backends that handle real money and real consequences. Modern Java (17+) is dramatically better than the Java of ten years ago -- records, sealed classes, pattern matching, and virtual threads have closed many of the gaps that drove people to other languages.

Kotlin sits on the same JVM and offers everything Java developers wish Java had: null safety, coroutines, data classes, extension functions, and concise syntax. The two languages interoperate seamlessly, and many teams adopt Kotlin incrementally alongside existing Java codebases.

Modern Java (17+)

Records

Records replace the boilerplate of POJOs. They are immutable data carriers with auto-generated equals(), hashCode(), and toString():

// Before: 40+ lines of boilerplate
// After: one line
public record User(String id, String name, String email, Instant createdAt) {}

// Usage
var user = new User("u1", "Alice", "alice@example.com", Instant.now());
System.out.println(user.name());  // "Alice"

// Records can have custom constructors for validation
public record Money(BigDecimal amount, String currency) {
    public Money {
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Amount cannot be negative");
        }
        Objects.requireNonNull(currency, "Currency is required");
    }
}

Sealed Classes

Sealed classes restrict which classes can extend them, enabling exhaustive pattern matching:

public sealed interface Shape
    permits Circle, Rectangle, Triangle {}

public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}
public record Triangle(double base, double height) implements Shape {}

// The compiler knows all subtypes -- exhaustive matching
public double area(Shape shape) {
    return switch (shape) {
        case Circle c -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
        case Triangle t -> 0.5 * t.base() * t.height();
        // No default needed -- compiler verifies exhaustiveness
    };
}

Pattern Matching

Pattern matching in switch and instanceof eliminates casting boilerplate:

// Pattern matching for instanceof
public String describe(Object obj) {
    if (obj instanceof String s && s.length() > 5) {
        return "Long string: " + s;
    }
    if (obj instanceof Integer i && i > 0) {
        return "Positive integer: " + i;
    }
    return "Unknown: " + obj;
}

// Switch expressions with guards
public String classify(Shape shape) {
    return switch (shape) {
        case Circle c when c.radius() > 100 -> "large circle";
        case Circle c -> "small circle";
        case Rectangle r when r.width() == r.height() -> "square";
        case Rectangle r -> "rectangle";
        case Triangle t -> "triangle";
    };
}

Virtual Threads (Project Loom)

Virtual threads are the biggest change to Java concurrency in 20 years. They make blocking I/O efficient without the complexity of reactive programming:

// Create millions of virtual threads -- each costs ~1KB vs ~1MB for platform threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> futures = urls.stream()
        .map(url -> executor.submit(() -> fetchUrl(url)))
        .toList();

    List<String> results = futures.stream()
        .map(f -> {
            try { return f.get(); }
            catch (Exception e) { return "error: " + e.getMessage(); }
        })
        .toList();
}

// In Spring Boot 3.2+, enable virtual threads with one property:
// spring.threads.virtual.enabled=true
// Every request handler now runs on a virtual thread automatically

Spring Boot Essentials

Spring Boot remains the dominant Java framework. These are the concepts that matter most.

Dependency Injection

@Service
public class OrderService {
    private final OrderRepository orderRepo;
    private final PaymentClient paymentClient;
    private final NotificationService notifications;

    // Constructor injection -- preferred over @Autowired on fields
    public OrderService(
            OrderRepository orderRepo,
            PaymentClient paymentClient,
            NotificationService notifications) {
        this.orderRepo = orderRepo;
        this.paymentClient = paymentClient;
        this.notifications = notifications;
    }

    @Transactional
    public Order placeOrder(CreateOrderRequest request) {
        var order = orderRepo.save(Order.from(request));
        paymentClient.charge(order.total(), request.paymentMethod());
        notifications.sendConfirmation(order);
        return order;
    }
}

Profiles and Configuration

// application.yml
// spring:
//   profiles:
//     active: ${SPRING_PROFILES_ACTIVE:dev}

// application-dev.yml  -- local development
// application-prod.yml -- production

@Configuration
@Profile("prod")
public class ProductionConfig {
    @Bean
    public DataSource dataSource(
            @Value("${db.url}") String url,
            @Value("${db.username}") String username,
            @Value("${db.password}") String password) {
        var ds = new HikariDataSource();
        ds.setJdbcUrl(url);
        ds.setUsername(username);
        ds.setPassword(password);
        ds.setMaximumPoolSize(20);
        return ds;
    }
}

Actuator for Production

Spring Boot Actuator provides production-ready endpoints out of the box:

# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health, metrics, info, prometheus
  endpoint:
    health:
      show-details: when-authorized
  health:
    db:
      enabled: true
    redis:
      enabled: true

Key endpoints: /actuator/health for liveness/readiness probes, /actuator/prometheus for metrics scraping, /actuator/info for build metadata.

JVM Memory Model and GC

Understanding JVM memory is essential for diagnosing production issues.

┌─────────────────────────────────────────┐
│                JVM Heap                  │
│  ┌──────────┐  ┌──────────────────────┐ │
│  │  Young    │  │     Old Generation   │ │
│  │  Gen      │  │                      │ │
│  │ Eden + S0 │  │  Long-lived objects  │ │
│  │    + S1   │  │                      │ │
│  └──────────┘  └──────────────────────┘ │
├─────────────────────────────────────────┤
│  Metaspace (class metadata, off-heap)   │
├─────────────────────────────────────────┤
│  Thread stacks (per-thread, off-heap)   │
└─────────────────────────────────────────┘

GC Tuning Basics

For most services, start with these and adjust based on metrics:

# G1GC (default since Java 9) -- good for most workloads
java -Xms512m -Xmx2g \
     -XX:+UseG1GC \
     -XX:MaxGCPauseMillis=200 \
     -XX:+HeapDumpOnOutOfMemoryError \
     -jar app.jar

# ZGC -- for low-latency services (sub-millisecond pauses)
java -Xms1g -Xmx4g \
     -XX:+UseZGC \
     -jar app.jar

Rules of thumb:

  • Set -Xms equal to -Xmx in production to avoid heap resizing
  • Enable -XX:+HeapDumpOnOutOfMemoryError always
  • Use G1GC unless you have a specific reason not to
  • ZGC for services where P99 latency matters more than throughput

Kotlin

Null Safety

Kotlin's null safety eliminates NullPointerException at compile time:

// Non-nullable by default
var name: String = "Alice"
name = null  // Compile error

// Nullable requires explicit ?
var email: String? = null

// Safe call operator
val length: Int? = email?.length

// Elvis operator for defaults
val displayName: String = email ?: "anonymous"

// Smart casting after null check
fun greet(user: User?) {
    if (user != null) {
        // user is automatically cast to non-null User here
        println("Hello, ${user.name}")
    }
}

Coroutines

Kotlin coroutines provide structured concurrency -- simpler than Java's virtual threads for complex async workflows:

suspend fun fetchUserProfile(userId: String): UserProfile = coroutineScope {
    val user = async { userService.getUser(userId) }
    val orders = async { orderService.getOrders(userId) }
    val preferences = async { preferenceService.get(userId) }

    UserProfile(
        user = user.await(),
        orders = orders.await(),
        preferences = preferences.await()
    )
}

// Structured concurrency: if any child fails, all are cancelled
// Timeout support built in
suspend fun fetchWithTimeout(userId: String): UserProfile =
    withTimeout(5.seconds) {
        fetchUserProfile(userId)
    }

Data Classes and Extension Functions

// Data class: like Java records but with copy()
data class User(
    val id: String,
    val name: String,
    val email: String,
    val role: Role = Role.USER
)

val user = User("u1", "Alice", "alice@example.com")
val admin = user.copy(role = Role.ADMIN)  // Immutable update

// Extension functions: add methods to existing types
fun String.toSlug(): String =
    this.lowercase()
        .replace(Regex("[^a-z0-9\\s-]"), "")
        .replace(Regex("\\s+"), "-")
        .trim('-')

val slug = "Hello World!".toSlug()  // "hello-world"

// Scoping functions
val result = userRepository.findById(id)?.let { user ->
    auditLog.record("accessed", user.id)
    user.toPublicProfile()
} ?: throw NotFoundException("User $id not found")

Kotlin vs Java: When to Use Which

AspectJavaKotlin
Null safetyOptional, annotationsBuilt into the type system
ConcisenessVerbose (improving)Concise by design
CoroutinesVirtual threads (Java 21+)First-class, structured
Data classesRecords (Java 16+)Data classes with copy()
Extension functionsNot availableFirst-class feature
Pattern matchingSwitch expressions (evolving)when expressions, smart casts
Ecosystem maturityMassive, decades of librariesFull Java interop
Hiring poolVery largeSmaller but growing
Learning curveWell-knownEasy for Java developers

Pragmatic advice: If starting a new JVM project, Kotlin is the better choice for developer productivity. If maintaining an existing Java codebase, modern Java (17+) is good enough -- do not rewrite for Kotlin's sake. If hiring is your bottleneck, Java has a larger candidate pool.

Enterprise Java in New Zealand

Java dominates NZ's enterprise sector:

  • Banking: ANZ and Westpac run core systems on Java/Spring. These are large, long-lived codebases with strict reliability requirements.
  • Insurance: Tower, Partners Life, and others rely on Java backends for policy management and claims processing.
  • Government and public sector: Datacom and other contractors build government systems primarily in Java and C#.
  • Xero: While Xero's newer services use Go and .NET, Java is part of their stack.

For senior roles in these organizations, deep Spring Boot knowledge, JVM tuning experience, and understanding of enterprise patterns (CQRS, event sourcing, saga patterns) are valued more than language novelty. Java may not be fashionable, but it pays well and the demand is consistent.

On this page