[{"content":" Code. Vibe. Break. Fix. Write about it. A technical blog where I document what I learn, the bugs I fight, and the solutions I find — mostly in Java, Spring Boot, and AWS, with the occasional Next.js or Vaadin detour.\nSometimes it's a tutorial. Sometimes it's a note to my future self. Sometimes someone else writes here too.\nWhat you'll find here Backend\r#\rJava, Spring Boot, REST APIs, microservices, design patterns, and everything that runs on the server side.\nCloud \u0026amp; DevOps\r#\rAWS services, infrastructure as code, CI/CD pipelines, Docker, monitoring, and scaling systems.\nFrontend \u0026amp; More\r#\rNext.js, Vaadin, JavaScript — the parts of the stack I\u0026rsquo;m still figuring out. Plus general dev notes and guides.\nNew here? Check out the latest posts or browse by category.\n","date":"8 February 2026","externalUrl":null,"permalink":"/","section":"","summary":" Code. Vibe. Break. Fix. Write about it. A technical blog where I document what I learn, the bugs I fight, and the solutions I find — mostly in Java, Spring Boot, and AWS, with the occasional Next.js or Vaadin detour.\nSometimes it's a tutorial. Sometimes it's a note to my future self. Sometimes someone else writes here too.\nWhat you'll find here Backend\r#\rJava, Spring Boot, REST APIs, microservices, design patterns, and everything that runs on the server side.\n","title":"","type":"page"},{"content":"","date":"8 February 2026","externalUrl":null,"permalink":"/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"},{"content":"","date":"8 February 2026","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"Every Java developer hits exceptions early. But the difference between checked and unchecked exceptions — and when to use which — trips people up for years. This post goes deep.\nThe Exception Hierarchy\r#\rEverything starts from Throwable:\nThrowable ├── Error (unchecked — don\u0026#39;t catch these) │ ├── OutOfMemoryError │ ├── StackOverflowError │ └── ... └── Exception (checked — must handle or declare) ├── IOException ├── SQLException ├── ... └── RuntimeException (unchecked — optional to handle) ├── NullPointerException ├── IllegalArgumentException ├── ArrayIndexOutOfBoundsException └── ...\rThe rule is simple:\nChecked exceptions extend Exception (but not RuntimeException). The compiler forces you to handle them. Unchecked exceptions extend RuntimeException. The compiler doesn\u0026rsquo;t care. Errors extend Error. These are JVM-level problems. Don\u0026rsquo;t catch them. Checked Exceptions: The Compiler Enforces\r#\rA checked exception means: \u0026ldquo;This thing can go wrong, and you must acknowledge it.\u0026rdquo;\nimport java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; void main() { try { readFile(); } catch (IOException ioException) { IO.println(\u0026#34;Failed to read file: %s\u0026#34;.formatted(ioException.getMessage())); } } void readFile() throws IOException { Files.readString(Path.of(\u0026#34;data.txt\u0026#34;)); }\rCommon checked exceptions:\nIOException — file/network operations SQLException — database operations ClassNotFoundException — reflection InterruptedException — thread operations Unchecked Exceptions: Your Problem, Your Choice\r#\rUnchecked exceptions are programming errors. The compiler won\u0026rsquo;t force you to catch them because they\u0026rsquo;re usually bugs you should fix, not conditions you should handle.\nvoid main() { // These throw unchecked exceptions at runtime String nullableText = null; // nullableText.length(); // NullPointerException int[] numbers = {1, 2, 3}; // numbers[5] = 10; // ArrayIndexOutOfBoundsException int parsedNumber = Integer.parseInt(\u0026#34;abc\u0026#34;); // NumberFormatException }\rCommon unchecked exceptions:\nNullPointerException — the classic IllegalArgumentException — bad method input IllegalStateException — object in wrong state UnsupportedOperationException — method not implemented NumberFormatException — bad number parsing ClassCastException — invalid cast Creating Your Own Exceptions\r#\rWhen you write your own, the checked vs unchecked decision matters:\n// Checked — callers MUST handle this public class InsufficientFundsException extends Exception { private final double amount; private final double balance; public InsufficientFundsException(double amount, double balance) { super(\u0026#34;Cannot withdraw %.2f - balance is only %.2f\u0026#34;.formatted(amount, balance)); this.amount = amount; this.balance = balance; } public double getAmount() { return amount; } public double getBalance() { return balance; } } // Unchecked — callers can choose to handle this public class InvalidTransactionException extends RuntimeException { public InvalidTransactionException(String message) { super(message); } }\rWhen to use which:\nUse checked when the caller can reasonably recover (retry, fallback, prompt user) Use unchecked when it\u0026rsquo;s a programming error (bad input validation, illegal state) Switch Expressions and Checked Exceptions\r#\rHere\u0026rsquo;s something that catches people off guard. A switch expression requires exhaustive cases — and the default branch can surface checked exceptions in surprising ways.\nThe problem\r#\rimport java.io.IOException; void main() { try { IO.println(parse(FileType.JSON, \u0026#34;{\\\u0026#34;key\\\u0026#34;: \\\u0026#34;value\\\u0026#34;}\u0026#34;)); } catch (IOException ioException) { IO.println(\u0026#34;Parse failed: %s\u0026#34;.formatted(ioException.getMessage())); } } enum FileType { CSV, JSON, XML } String parse(FileType type, String data) throws IOException { return switch (type) { case CSV -\u0026gt; parseCsv(data); case JSON -\u0026gt; parseJson(data); case XML -\u0026gt; parseXml(data); }; } String parseCsv(String data) throws IOException { if (data.isEmpty()) throw new IOException(\u0026#34;Empty CSV data\u0026#34;); return \u0026#34;parsed-csv: %s\u0026#34;.formatted(data); } String parseJson(String data) throws IOException { if (data.isEmpty()) throw new IOException(\u0026#34;Empty JSON data\u0026#34;); return \u0026#34;parsed-json: %s\u0026#34;.formatted(data); } String parseXml(String data) throws IOException { if (data.isEmpty()) throw new IOException(\u0026#34;Empty XML data\u0026#34;); return \u0026#34;parsed-xml: %s\u0026#34;.formatted(data); }\rThis works because the enum is exhaustive. But what if you use a String or int in a switch expression?\nstatic String describe(int statusCode) { return switch (statusCode) { case 200 -\u0026gt; \u0026#34;OK\u0026#34;; case 404 -\u0026gt; \u0026#34;Not Found\u0026#34;; case 500 -\u0026gt; \u0026#34;Internal Server Error\u0026#34;; default -\u0026gt; throw new IllegalArgumentException( \u0026#34;Unknown status code: \u0026#34; + statusCode ); // default throws an unchecked exception — no problem }; }\rIf the default branch called a method that throws a checked exception, the whole switch expression would need to declare or handle it — even though you might think default \u0026ldquo;never\u0026rdquo; happens.\n// This forces the caller to handle IOException // even for status codes like 200 and 404 import java.nio.file.Files; import java.nio.file.Path; static String processStatus(int code) throws IOException { return switch (code) { case 200 -\u0026gt; \u0026#34;OK\u0026#34;; case 404 -\u0026gt; \u0026#34;Not Found\u0026#34;; default -\u0026gt; loadFromFile(code); // throws IOException }; } static String loadFromFile(int code) throws IOException { return new String(Files.readAllBytes( Path.of(\u0026#34;status_\u0026#34; + code + \u0026#34;.txt\u0026#34;) )); }\rThis is a design decision to be aware of. If your default branch introduces a checked exception, it infects the entire switch expression.\nRecords and Exceptions\r#\rRecords are great for data. But their compact constructors are a natural place for validation — and exception choices matter here.\nvoid main() { var person = new Person( \u0026#34;Tebogo\u0026#34;, new Email(\u0026#34;TEBOGO@EXAMPLE.COM\u0026#34;), new Age(28) ); IO.println(person); try { new Email(\u0026#34;not-an-email\u0026#34;); } catch (IllegalArgumentException e) { IO.println(\u0026#34;Caught: %s\u0026#34;.formatted(e.getMessage())); } try { new Age(-5); } catch (IllegalArgumentException e) { IO.println(\u0026#34;Caught: %s\u0026#34;.formatted(e.getMessage())); } } record Email(String address) { Email { if (address == null || address.isBlank()) { throw new IllegalArgumentException(\u0026#34;Email cannot be blank\u0026#34;); } if (!address.contains(\u0026#34;@\u0026#34;)) { throw new IllegalArgumentException(\u0026#34;Invalid email: %s\u0026#34;.formatted(address)); } address = address.toLowerCase().strip(); } } record Age(int value) { Age { if (value \u0026lt; 0 || value \u0026gt; 150) { throw new IllegalArgumentException(\u0026#34;Age must be between 0 and 150, got: %d\u0026#34;.formatted(value)); } } } record Person(String name, Email email, Age age) {}\rWhy unchecked here? Because passing invalid data to a constructor is a programming error. The caller should validate before constructing. You wouldn\u0026rsquo;t use a checked exception for new ArrayList\u0026lt;\u0026gt;(-1) — same logic.\nException: If your record wraps data from external input (user form, API request, file), you might want a checked exception to force callers to handle the validation failure. In that case, use a static factory method instead:\nrecord Email(String address) { Email { // Still validate, but this is the \u0026#34;trusted\u0026#34; path assert address != null \u0026amp;\u0026amp; address.contains(\u0026#34;@\u0026#34;); address = address.toLowerCase().strip(); } // Factory method with checked exception for untrusted input static Email parse(String raw) throws InvalidEmailException { if (raw == null || !raw.contains(\u0026#34;@\u0026#34;)) { throw new InvalidEmailException(raw); } return new Email(raw); } }\rSealed Interfaces + Pattern Matching + Exceptions\r#\rSealed interfaces define a closed set of implementations. Combined with pattern matching in switch, you get exhaustive type checking — and no default branch needed.\nimport java.io.IOException; void main() { Result\u0026lt;String\u0026gt; successfulResult = new Success\u0026lt;\u0026gt;(\u0026#34;data loaded\u0026#34;); Result\u0026lt;String\u0026gt; failedResult = new Failure\u0026lt;\u0026gt;(new IOException(\u0026#34;timeout\u0026#34;)); Result\u0026lt;String\u0026gt; pendingResult = new Pending\u0026lt;\u0026gt;(\u0026#34;task-42\u0026#34;); IO.println(describe(successfulResult)); IO.println(describe(failedResult)); IO.println(describe(pendingResult)); } sealed interface Result\u0026lt;T\u0026gt; permits Success, Failure, Pending {} record Success\u0026lt;T\u0026gt;(T value) implements Result\u0026lt;T\u0026gt; {} record Failure\u0026lt;T\u0026gt;(Exception error) implements Result\u0026lt;T\u0026gt; {} record Pending\u0026lt;T\u0026gt;(String taskId) implements Result\u0026lt;T\u0026gt; {} \u0026lt;T\u0026gt; String describe(Result\u0026lt;T\u0026gt; result) { return switch (result) { case Success\u0026lt;T\u0026gt;(var value) -\u0026gt; \u0026#34;Success: %s\u0026#34;.formatted(value); case Failure\u0026lt;T\u0026gt;(var error) -\u0026gt; \u0026#34;Failed: %s\u0026#34;.formatted(error.getMessage()); case Pending\u0026lt;T\u0026gt;(var taskId) -\u0026gt; \u0026#34;Pending: task %s\u0026#34;.formatted(taskId); }; }\rThis pattern is powerful. You\u0026rsquo;ve essentially built a Result type (similar to Rust\u0026rsquo;s Result) using sealed interfaces and records. The compiler guarantees you handle every case.\nUsing this for error handling instead of exceptions\r#\rvoid main() { var divisionResult = divide(10, 3); switch (divisionResult) { case Ok\u0026lt;Integer\u0026gt;(var value) -\u0026gt; IO.println(\u0026#34;Result: %d\u0026#34;.formatted(value)); case Err\u0026lt;Integer\u0026gt;(var message, _) -\u0026gt; IO.println(\u0026#34;Error: %s\u0026#34;.formatted(message)); } var parsedResult = parseInt(\u0026#34;abc\u0026#34;); switch (parsedResult) { case Ok\u0026lt;Integer\u0026gt;(var value) -\u0026gt; IO.println(\u0026#34;Parsed: %d\u0026#34;.formatted(value)); case Err\u0026lt;Integer\u0026gt;(var message, _) -\u0026gt; IO.println(\u0026#34;Parse error: %s\u0026#34;.formatted(message)); } } sealed interface Result\u0026lt;T\u0026gt; permits Ok, Err {} record Ok\u0026lt;T\u0026gt;(T value) implements Result\u0026lt;T\u0026gt; {} record Err\u0026lt;T\u0026gt;(String message, Exception cause) implements Result\u0026lt;T\u0026gt; { Err(String message) { this(message, null); } } Result\u0026lt;Integer\u0026gt; divide(int dividend, int divisor) { if (divisor == 0) { return new Err\u0026lt;\u0026gt;(\u0026#34;Cannot divide by zero\u0026#34;); } return new Ok\u0026lt;\u0026gt;(dividend / divisor); } Result\u0026lt;Integer\u0026gt; parseInt(String text) { try { return new Ok\u0026lt;\u0026gt;(Integer.parseInt(text)); } catch (NumberFormatException e) { return new Err\u0026lt;\u0026gt;(\u0026#34;Not a number: %s\u0026#34;.formatted(text), e); } }\rThis gives you explicit error handling without the overhead of exception stack traces. Good for expected failures. Keep exceptions for truly exceptional situations.\nWhat the Compiler Actually Does with Switch — The Hidden Default\r#\rHere\u0026rsquo;s the part most tutorials skip. When you write an exhaustive switch over a sealed type and leave out default, the compiler still inserts one behind the scenes. And it throws a checked-style error.\nThe source code\r#\rsealed interface Animal permits Dog, Cat {} record Dog(String name) implements Animal {} record Cat(String name) implements Animal {} String sound(Animal animal) { return switch (animal) { case Dog(var name) -\u0026gt; \u0026#34;%s says woof\u0026#34;.formatted(name); case Cat(var name) -\u0026gt; \u0026#34;%s says meow\u0026#34;.formatted(name); }; } void main() { IO.println(sound(new Dog(\u0026#34;Rex\u0026#34;))); IO.println(sound(new Cat(\u0026#34;Whiskers\u0026#34;))); }\rThis compiles and runs fine. The compiler is satisfied — Animal is sealed, and all permitted subtypes are covered. No default needed.\nBut what does the compiled bytecode actually look like?\nProving it with javap\r#\rCompile and disassemble:\njavac HiddenDefault.java javap -c HiddenDefault\rIn the bytecode output for the sound method, you\u0026rsquo;ll find something like this at the end of the switch:\n// ... the case matching logic ... default -\u0026gt; throw new IncompatibleClassChangeError(...)\rThe compiler inserts a hidden default branch that throws IncompatibleClassChangeError. This is an Error (extends LinkageError extends Error), not a regular exception — so it doesn\u0026rsquo;t require a throws declaration.\nWhy does the compiler do this?\r#\rBecause Java compiles separately. Consider this scenario:\nYou compile HiddenDefault.java against version 1 of Animal (sealed, permits Dog and Cat) Someone later adds record Bird(String name) implements Animal {} and recompiles only Animal.java — not your switch At runtime, your switch receives a Bird — a type that didn\u0026rsquo;t exist when you compiled The JVM can\u0026rsquo;t just crash silently. The hidden default catches this and throws IncompatibleClassChangeError — telling you the class structure changed incompatibly since compile time.\nWhat this means for exception handling\r#\rThis is important for understanding how Java protects you:\nThe compiler proves exhaustiveness at compile time → no default needed in source The JVM protects against binary incompatibility at runtime → hidden default throws IncompatibleClassChangeError Because it\u0026rsquo;s an Error (not Exception), it doesn\u0026rsquo;t pollute your method signature with throws This is fundamentally different from a regular default that you write yourself. Your default would typically throw an unchecked IllegalArgumentException or IllegalStateException. The compiler\u0026rsquo;s hidden default throws a linkage error because the problem isn\u0026rsquo;t bad input — it\u0026rsquo;s that the class hierarchy changed out from under you.\nContrast: enum switch\r#\rThe same thing happens with enums. If you cover all enum constants:\nenum Direction { NORTH, SOUTH, EAST, WEST } static String arrow(Direction direction) { return switch (direction) { case NORTH -\u0026gt; \u0026#34;↑\u0026#34;; case SOUTH -\u0026gt; \u0026#34;↓\u0026#34;; case EAST -\u0026gt; \u0026#34;→\u0026#34;; case WEST -\u0026gt; \u0026#34;←\u0026#34;; }; }\rThe compiled bytecode still has a hidden default. If someone adds NORTHEAST to the enum and recompiles only the enum, your switch would hit that hidden default at runtime and throw IncompatibleClassChangeError.\nYou can verify this yourself:\njavac Direction.java EnumSwitch.java javap -c EnumSwitch\rLook for the default branch in the tableswitch or lookupswitch instruction — it\u0026rsquo;s always there.\nnull: The Special Case in Switch\r#\rnull gets its own rules in switch expressions, and it interacts with exceptions in ways that might surprise you.\nBefore Java 21: null always throws\r#\rIn traditional switch statements, passing null throws NullPointerException before any case is evaluated:\nstatic String old_style(String text) { // If text is null, this throws NullPointerException // BEFORE any case is checked return switch (text) { case \u0026#34;hello\u0026#34; -\u0026gt; \u0026#34;greeting\u0026#34;; case \u0026#34;bye\u0026#34; -\u0026gt; \u0026#34;farewell\u0026#34;; default -\u0026gt; \u0026#34;unknown\u0026#34;; }; } void main() { old_style(null); // NullPointerException — default doesn\u0026#39;t catch it }\rThat\u0026rsquo;s right — default does NOT catch null. The NPE happens before the switch logic even starts.\nJava 21+: explicit null case\r#\rPattern matching switch lets you handle null explicitly:\nstatic String withNullCase(String text) { return switch (text) { case \u0026#34;hello\u0026#34; -\u0026gt; \u0026#34;greeting\u0026#34;; case \u0026#34;bye\u0026#34; -\u0026gt; \u0026#34;farewell\u0026#34;; case null -\u0026gt; \u0026#34;nothing\u0026#34;; // Explicit null handling default -\u0026gt; \u0026#34;unknown\u0026#34;; }; }\rNow null is caught by the case null branch instead of throwing NPE.\nnull with sealed interfaces\r#\rThis is where it gets interesting. Even with a sealed interface where you cover all subtypes, null is still not covered:\nsealed interface Shape permits Circle, Square {} record Circle(double radius) implements Shape {} record Square(double sideLength) implements Shape {} static String describe(Shape shape) { return switch (shape) { case Circle circle -\u0026gt; \u0026#34;circle radius=\u0026#34; + circle.radius(); case Square square -\u0026gt; \u0026#34;square side=\u0026#34; + square.sideLength(); // Exhaustive for non-null values — compiles fine }; }\rThis compiles. But at runtime:\ndescribe(null); // NullPointerException!\rThe sealed interface exhaustiveness check covers all types but not null. If you want null-safety, you need to add it:\nstatic String describeSafe(Shape shape) { return switch (shape) { case Circle c -\u0026gt; \u0026#34;circle r=\u0026#34; + c.r(); case Square s -\u0026gt; \u0026#34;square s=\u0026#34; + s.s(); case null -\u0026gt; \u0026#34;no shape\u0026#34;; }; }\rCompiled proof: how null is handled\r#\rCompile and disassemble a switch with a case null:\njavac NullSwitch.java javap -c NullSwitch\rYou\u0026rsquo;ll see that the compiler generates a null check before the type-checking logic. The bytecode roughly does:\n1. if (input == null) → jump to null case label 2. if (input instanceof Circle) → jump to circle case 3. if (input instanceof Square) → jump to square case 4. default → throw IncompatibleClassChangeError\rnull is checked first, as a special case, separate from the type pattern matching. This is why default doesn\u0026rsquo;t catch null — null checking and type matching are different operations at the bytecode level.\nnull + guards\r#\rYou can combine null with guard conditions too:\nstatic String process(Object obj) { return switch (obj) { case null -\u0026gt; \u0026#34;null input\u0026#34;; case String text when text.isEmpty() -\u0026gt; \u0026#34;empty string\u0026#34;; case String text -\u0026gt; \u0026#34;string: \u0026#34; + text; case Integer number when number \u0026lt; 0 -\u0026gt; \u0026#34;negative: \u0026#34; + number; case Integer number -\u0026gt; \u0026#34;number: \u0026#34; + number; default -\u0026gt; \u0026#34;other: \u0026#34; + obj.getClass().getSimpleName(); }; }\rThe case null must come before other cases or be combined with default:\n// Also valid — null and default combined return switch (obj) { case String text -\u0026gt; \u0026#34;string\u0026#34;; case Integer number -\u0026gt; \u0026#34;number\u0026#34;; case null, default -\u0026gt; \u0026#34;null or something else\u0026#34;; };\rThis is a clean pattern when you want to treat null the same as an unknown type.\nPractical Guidelines\r#\rUse checked exceptions when:\nThe caller can realistically recover (retry, fallback, prompt user) It\u0026rsquo;s an external system failure (file not found, network down, DB unavailable) You want the compiler to enforce handling Use unchecked exceptions when:\nIt\u0026rsquo;s a programming error (null where it shouldn\u0026rsquo;t be, invalid argument) The caller can\u0026rsquo;t do anything useful about it You\u0026rsquo;re writing library code and don\u0026rsquo;t want to pollute every method signature General rules:\nDon\u0026rsquo;t catch Exception or Throwable broadly — catch specific types Don\u0026rsquo;t use exceptions for control flow (expensive and confusing) Don\u0026rsquo;t swallow exceptions silently — at minimum, log them Prefer unchecked for new code unless you have a strong reason for checked Use sealed interfaces + Result pattern for expected failures in business logic Summary\r#\rChecked Unchecked Extends Exception RuntimeException Compiler enforced Yes No Must declare with throws Yes No Typical use External failures Programming errors Examples IOException, SQLException NullPointerException, IllegalArgumentException In switch default Infects the whole expression No impact In record constructors Awkward — use factory methods Natural fit for validation Compiler\u0026rsquo;s hidden default N/A — uses IncompatibleClassChangeError (Error, not Exception) N/A null in switch NPE thrown before switch logic (pre-Java 21) case null handles it explicitly (Java 21+) Next in this series, we\u0026rsquo;ll look at building something real with these concepts.\n","date":"8 February 2026","externalUrl":null,"permalink":"/posts/java-exceptions-checked-vs-unchecked/","section":"Posts","summary":"Every Java developer hits exceptions early. But the difference between checked and unchecked exceptions — and when to use which — trips people up for years. This post goes deep.\nThe Exception Hierarchy\r#\rEverything starts from Throwable:\nThrowable ├── Error (unchecked — don't catch these) │ ├── OutOfMemoryError │ ├── StackOverflowError │ └── ... └── Exception (checked — must handle or declare) ├── IOException ├── SQLException ├── ... └── RuntimeException (unchecked — optional to handle) ├── NullPointerException ├── IllegalArgumentException ├── ArrayIndexOutOfBoundsException └── ...\rThe rule is simple:\n","title":"Checked vs Unchecked Exceptions in Java — A Deep Dive","type":"posts"},{"content":"","date":"8 February 2026","externalUrl":null,"permalink":"/tags/error-handling/","section":"Tags","summary":"","title":"Error-Handling","type":"tags"},{"content":"","date":"8 February 2026","externalUrl":null,"permalink":"/tags/exceptions/","section":"Tags","summary":"","title":"Exceptions","type":"tags"},{"content":"","date":"8 February 2026","externalUrl":null,"permalink":"/categories/java/","section":"Categories","summary":"","title":"Java","type":"categories"},{"content":"","date":"8 February 2026","externalUrl":null,"permalink":"/tags/java/","section":"Tags","summary":"","title":"Java","type":"tags"},{"content":"","date":"8 February 2026","externalUrl":null,"permalink":"/series/java-from-scratch/","section":"Series","summary":"","title":"Java From Scratch","type":"series"},{"content":"","date":"8 February 2026","externalUrl":null,"permalink":"/tags/pattern-matching/","section":"Tags","summary":"","title":"Pattern-Matching","type":"tags"},{"content":"","date":"8 February 2026","externalUrl":null,"permalink":"/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","date":"8 February 2026","externalUrl":null,"permalink":"/tags/records/","section":"Tags","summary":"","title":"Records","type":"tags"},{"content":"","date":"8 February 2026","externalUrl":null,"permalink":"/tags/sealed-interfaces/","section":"Tags","summary":"","title":"Sealed-Interfaces","type":"tags"},{"content":"","date":"8 February 2026","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"},{"content":"","date":"8 February 2026","externalUrl":null,"permalink":"/tags/switch/","section":"Tags","summary":"","title":"Switch","type":"tags"},{"content":"","date":"8 February 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"Backend engineer based in South Africa. Java \u0026amp; Spring Boot by day, AWS certified, occasionally venturing into frontend territory. Building large-scale systems and writing about it.\nGitHub | LinkedIn | CV\n","date":"8 February 2026","externalUrl":null,"permalink":"/authors/tebogo-nkwane/","section":"Authors","summary":"Backend engineer based in South Africa. Java \u0026 Spring Boot by day, AWS certified, occasionally venturing into frontend territory. Building large-scale systems and writing about it.\nGitHub | LinkedIn | CV\n","title":"Tebogo Nkwane","type":"authors"},{"content":"","date":"8 February 2026","externalUrl":null,"permalink":"/tags/intellij/","section":"Tags","summary":"","title":"Intellij","type":"tags"},{"content":"","date":"8 February 2026","externalUrl":null,"permalink":"/tags/java-25/","section":"Tags","summary":"","title":"Java-25","type":"tags"},{"content":"","date":"8 February 2026","externalUrl":null,"permalink":"/tags/jdk/","section":"Tags","summary":"","title":"Jdk","type":"tags"},{"content":"Java 25 is here, and if you\u0026rsquo;re on Windows you have a few options for getting it running locally. This post walks through the full setup — from downloading the JDK to running your first program in the console.\nOption 1: Download the JDK Directly\r#\rI\u0026rsquo;d recommend sticking with open-source JDK distributions. Here are the main options:\nProvider URL Notes Adoptium (Eclipse Temurin) adoptium.net Open-source, community-driven — my go-to Azul Zulu azul.com/downloads Open-source, well-maintained, good docs OpenJDK jdk.java.net The upstream source — no installer, just archives Oracle\u0026rsquo;s JDK is free for non-commercial use, but the licensing has changed multiple times over the years and it\u0026rsquo;s caused enough confusion that I just avoid it. The open-source builds are functionally identical — same codebase, same features, no licensing headaches.\nPick one. For most people, Adoptium (Eclipse Temurin) is the easiest starting point. Download the .msi installer for Windows x64.\nInstall it\r#\rRun the installer. The default install path is usually something like:\nC:\\Program Files\\Eclipse Adoptium\\jdk-25.0.2.10-hotspot\rSet JAVA_HOME\r#\rBefore running commands, decide whether you want a temporary setup (current terminal only) or a permanent setup (recommended).\nOption A: Temporary (current terminal only)\r#\rUse this if you just want to test quickly in one open cmd session:\nset JAVA_HOME=C:\\Program Files\\Eclipse Adoptium\\jdk-25.0.2.10-hotspot set PATH=%JAVA_HOME%\\bin;%PATH%\rThis is not saved. Closing the terminal resets it.\nOption B: Permanent (recommended)\r#\rYou can do this in the Environment Variables UI:\nOpen System Properties → Advanced → Environment Variables Add or update JAVA_HOME Value (whatever was installed or downloaded): C:\\Program Files\\Eclipse Adoptium\\jdk-25.0.2.10-hotspot Edit Path and add %JAVA_HOME%\\bin Or do it from command line:\nUser-level (no admin): setx JAVA_HOME \u0026#34;C:\\Program Files\\Eclipse Adoptium\\jdk-25.0.2.10-hotspot\u0026#34; setx PATH \u0026#34;%PATH%;%JAVA_HOME%\\bin\u0026#34;\rSystem-level (admin terminal): setx JAVA_HOME \u0026#34;C:\\Program Files\\Eclipse Adoptium\\jdk-25.0.2.10-hotspot\u0026#34; /M setx PATH \u0026#34;%PATH%;%JAVA_HOME%\\bin\u0026#34; /M\r/M means machine-level (system-wide) variables. Without /M, setx writes user-level variables only.\nClose and reopen your terminal, then verify:\necho %JAVA_HOME% where java java --version\rYou should see something like:\nopenjdk 25.0.2 2026-01-20 LTS OpenJDK Runtime Environment Temurin-25.0.2+10 (build 25.0.2+10-LTS) OpenJDK 64-Bit Server VM Temurin-25.0.2+10 (build 25.0.2+10-LTS, mixed mode, sharing)\rOption 2: Use IntelliJ\u0026rsquo;s .jdks Directory\r#\rThis is my preferred approach for quickly testing multiple JDK versions while developing in IntelliJ. For a stable default setup on Windows, install one JDK system-wide, set JAVA_HOME, and keep %JAVA_HOME%\\bin in PATH. IntelliJ manages downloaded JDKs in a .jdks directory inside your user folder.\nHow it works\r#\rIntelliJ stores downloaded JDKs at:\nC:\\Users\\\u0026lt;your-username\u0026gt;\\.jdks\\\rSo you might end up with:\nC:\\Users\\tg\\.jdks\\openjdk-21.0.2\\ C:\\Users\\tg\\.jdks\\openjdk-25\\ C:\\Users\\tg\\.jdks\\corretto-17.0.9\\\rEach one is a fully functional JDK. No installer needed.\nDownload a JDK through IntelliJ\r#\rOpen IntelliJ IDEA Go to File → Project Structure (or press Ctrl + Alt + Shift + S) Under Platform Settings → SDKs, click the + button Select Download JDK Pick your vendor (e.g., Oracle OpenJDK, Temurin, Corretto) and version 25 or 26 IntelliJ downloads it to ~/.jdks/ automatically That\u0026rsquo;s it. No system-wide install, no PATH changes, no admin rights needed.\nUse the .jdks JDK from the command line\r#\rIf you want to use this JDK outside IntelliJ too, just point to it directly:\n\u0026#34;C:\\Users\\tg\\.jdks\\temurin-21.0.9\\bin\\java.exe\u0026#34; --version\rOr set it as your JAVA_HOME temporarily in that terminal session:\nset JAVA_HOME=C:\\Users\\tg\\.jdks\\temurin-21.0.9 set PATH=%JAVA_HOME%\\bin;%PATH% java --version\rThis is great for quickly switching between Java versions without messing with your system config.\nYour First Java 25 Program\r#\rBreak time: stretch, sip water, then come back for code.\nCreate a file called Hello.java:\nvoid main() { IO.println(\u0026#34;Hello from Java 25!\u0026#34;); IO.println(\u0026#34;Java version: \u0026#34; + System.getProperty(\u0026#34;java.version\u0026#34;)); IO.println(\u0026#34;Java home: \u0026#34; + System.getProperty(\u0026#34;java.home\u0026#34;)); }\rCompile and run it:\njavac Hello.java java Hello\rOutput:\nHello from Java 25! Java version: 25 Java home: C:\\Users\\tg\\.jdks\\openjdk-25\rEven simpler: run without compiling\r#\rSince Java 11 (JEP 330), you can run single-file programs directly:\njava Hello.java\rNo javac step needed. Java compiles it in memory and runs it.\nFrom Java 22 (JEP 458), Java also supports launching multi-file source-code programs. That means java can run source programs that span multiple .java files, not just one file.\nTry Some Java 25 Features\r#\rLet\u0026rsquo;s make sure the setup works with a quick set of modern Java examples.\nModern String Formatting with .formatted()\r#\rQuick context: String Templates are a separate Java feature, with preview rounds in JEP 430 and JEP 459. They feel similar to JavaScript backticks and Python f-strings. Think:\nJavaScript: `Hello ${name}` Python: f\u0026quot;Hello {name}\u0026quot; Java template style: STR.\u0026quot;Hello \\{name}\u0026quot; I do not recommend STR for production yet because preview features can still change, require --enable-preview, and can complicate upgrades between JDK versions.\nIn this post, we use .formatted() because it is stable and works out of the box.\nvoid main() { var name = \u0026#34;Tebogo\u0026#34;; var year = 2026; // Modern string formatting with .formatted() IO.println(\u0026#34;Hello, %s! Welcome to %d.\u0026#34;.formatted(name, year)); IO.println(\u0026#34;Next year will be %d\u0026#34;.formatted(year + 1)); IO.println(\u0026#34;Pi to 4 decimals: %.4f\u0026#34;.formatted(Math.PI)); }\rRecords (stable since Java 16)\r#\rThink of records as Java\u0026rsquo;s built-in data classes (or data carriers).\nThe \u0026ldquo;buy 1, get 5 free\u0026rdquo; version is:\ndefine the state once, get constructor, accessors, equals(), hashCode(), and toString() generated for you. That means less boilerplate and clearer intent when a type is mostly about holding data.\nvoid main() { var point = new Point(10, 20); IO.println(point); IO.println(point.xCoordinate()); IO.println(point.yCoordinate()); var person = new Person(\u0026#34;Alex\u0026#34;, 34); IO.println(person); IO.println(person.name()); } record Point(int xCoordinate, int yCoordinate) {} record Person(String name, int age) { Person { if (age \u0026lt; 0) throw new IllegalArgumentException(\u0026#34;Age cannot be negative\u0026#34;); } }\rPattern Matching with switch (stable since Java 21)\r#\rPattern matching lets Java unpack values while checking their type, so your code reads in a more functional style instead of nested if + casts.\nIt gets especially powerful with records because you can match the record type and immediately bind meaningful names (like radius, width, height) right inside the switch cases.\nvoid main() { var shapes = new Shape[] { new Circle(5), new Rectangle(4, 6), new Triangle(3, 8) }; for (var shape : shapes) { IO.println(\u0026#34;%s -\u0026gt; area = %.2f\u0026#34;.formatted(shape, area(shape))); } } sealed interface Shape permits Circle, Rectangle, Triangle {} record Circle(double radius) implements Shape {} record Rectangle(double width, double height) implements Shape {} record Triangle(double base, double height) implements Shape {} static double area(Shape shape) { return switch (shape) { case Circle(var radius) -\u0026gt; Math.PI * radius * radius; case Rectangle(var width, var height) -\u0026gt; width * height; case Triangle(var base, var height) -\u0026gt; 0.5 * base * height; }; }\rRun it:\njava PatternSwitchDemo.java\rCircle[radius=5.0] → area = 78.54 Rectangle[width=4.0, height=6.0] → area = 24.00 Triangle[base=3.0, height=8.0] → area = 12.00\rTroubleshooting\r#\rjava is not recognized as an internal or external command Your PATH doesn\u0026rsquo;t include the JDK bin directory. Double-check JAVA_HOME and make sure %JAVA_HOME%\\bin is on your PATH. Open a new terminal after setting it.\nWrong Java version showing up You might have an older JDK on your PATH. Run where java to see which one Windows is finding first. The one at the top wins.\nwhere java\rIf the wrong one is first, either remove the old JDK from your PATH or put the new one earlier in the PATH variable.\nIntelliJ is using a different JDK than expected Check File → Project Structure → Project → SDK. Make sure it points to your Java 25 JDK. Also check Settings → Build, Execution, Deployment → Build Tools → Gradle (or Maven) if you\u0026rsquo;re using a build tool — those have their own JDK settings.\nSummary\r#\rDownload a JDK from Adoptium, Oracle, Corretto, or Azul For a stable default on Windows, install one JDK and set system JAVA_HOME + PATH Use IntelliJ .jdks when you want to quickly test multiple Java versions during development Verify your active Java with echo %JAVA_HOME%, where java, and java --version Run source directly: single-file since Java 11 (JEP 330), multi-file launch since Java 22 (JEP 458) Test the new features: records, sealed interfaces, pattern matching with switch Next up in this series: Checked vs Unchecked Exceptions in Java — A Deep Dive.\n","date":"8 February 2026","externalUrl":null,"permalink":"/posts/java-25-setup-windows/","section":"Posts","summary":"Java 25 is here, and if you’re on Windows you have a few options for getting it running locally. This post walks through the full setup — from downloading the JDK to running your first program in the console.\nOption 1: Download the JDK Directly\r#\rI’d recommend sticking with open-source JDK distributions. Here are the main options:\n","title":"Running Java 25 Locally on Windows — The Full Setup","type":"posts"},{"content":"","date":"8 February 2026","externalUrl":null,"permalink":"/tags/setup/","section":"Tags","summary":"","title":"Setup","type":"tags"},{"content":"","date":"8 February 2026","externalUrl":null,"permalink":"/tags/windows/","section":"Tags","summary":"","title":"Windows","type":"tags"},{"content":"","date":"7 February 2026","externalUrl":null,"permalink":"/categories/general/","section":"Categories","summary":"","title":"General","type":"categories"},{"content":"Every developer has a blog they keep meaning to start. This is mine.\nI\u0026rsquo;ve been a backend engineer for a few years now, mostly working with Java and Spring Boot on large-scale systems. Along the way I\u0026rsquo;ve accumulated a lot of notes, debugging steps, architectural decisions, AWS gotchas, the occasional frontend struggle. Most of them live in handwritten notes, Notepad++, Obsidian, and the occasional PlantUML or Mermaid diagram. I\u0026rsquo;ve also shared some of these notes with colleagues on Teams, and sometimes on Slack.\nThis blog is where those notes graduate to.\nWhy \u0026ldquo;Errors\u0026rdquo;?\r#\rBecause errors are where the real learning happens. Not the clean tutorial code that compiles on the first try (but never works as expected), the NullPointerException at 2am, the CloudFormation stack that rolls back for reasons nobody understands, the Spring Boot config that works locally but not in production.\nWhat You\u0026rsquo;ll Find Here\r#\rJava \u0026amp; Spring Boot — the stuff I work with every day. Patterns, pitfalls, performance tuning. AWS — Lambda, DynamoDB, Glue, EventBridge, CloudFormation just to name a few. Real-world usage, not just docs rewrites. Vaadin — currently learning this for full-stack Java web apps. Next.js \u0026amp; Frontend — not my strong suit, but I\u0026rsquo;m working on it. Architecture — DDD, event-driven systems, scaling things that need to handle millions of requests. Teaching \u0026amp; Learning — sometimes the best way to learn something is to explain it to someone else. The Format\r#\rSome posts will be proper tutorials. Some will be rough notes — the kind of thing I\u0026rsquo;d search for six months from now when I hit the same problem again. Some might be written by collaborators.\nNo bullsh*t. Just code, context, and the occasional opinion.\nvoid main() { IO.println(\u0026#34;Let\u0026#39;s get started.\u0026#34;); IO.println(\u0026#34;// Danko.\u0026#34;); }\rIf you want to know more about me, check the about page.\n","date":"7 February 2026","externalUrl":null,"permalink":"/posts/hello-world/","section":"Posts","summary":"Every developer has a blog they keep meaning to start. This is mine.\nI’ve been a backend engineer for a few years now, mostly working with Java and Spring Boot on large-scale systems. Along the way I’ve accumulated a lot of notes, debugging steps, architectural decisions, AWS gotchas, the occasional frontend struggle. Most of them live in handwritten notes, Notepad++, Obsidian, and the occasional PlantUML or Mermaid diagram. I’ve also shared some of these notes with colleagues on Teams, and sometimes on Slack.\n","title":"Hello World — Why This Blog Exists","type":"posts"},{"content":"","date":"7 February 2026","externalUrl":null,"permalink":"/tags/meta/","section":"Tags","summary":"","title":"Meta","type":"tags"},{"content":"","date":"7 February 2026","externalUrl":null,"permalink":"/tags/welcome/","section":"Tags","summary":"","title":"Welcome","type":"tags"},{"content":"\rHey, I\u0026rsquo;m Tebogo Nkwane\r#\rI\u0026rsquo;m a backend engineer based in South Africa. I build large-scale systems, event-driven architectures, cloud infrastructure, and the kind of backend work that keeps things running at scale.\nThe Stack\r#\rWhat I know well:\nJava — Spring Boot, Spring Cloud Gateway, Spring WebFlux, Jakarta EE, the whole ecosystem AWS — Certified Developer. CDK, CloudFormation, Lambda, ELB, SNS, SQS, EventBridge, S3 and more Databases — PostgreSQL, DynamoDB, MySQL, DB2 DevOps — Docker, CI/CD pipelines, deployment as code What I\u0026rsquo;m learning:\nVaadin — building full-stack Java web apps Next.js — frontend framework for React, still getting comfortable here What I dabble in:\nJavaScript, Python, C# (.NET Core) Grafana, Prometheus, ELK Stack for monitoring Architecture patterns — DDD, TOGAF, SOLID Why \u0026ldquo;Errors\u0026rdquo;?\r#\rBecause that\u0026rsquo;s where the learning happens. Every stack trace, failed build, and late-night debugging session teaches something worth sharing.\nThis blog is where I document those lessons — sometimes as tutorials, sometimes as notes to my future self, and sometimes with input from collaborators.\nOutside Interests\r#\rI try to step away from the screen sometimes. When I do, you might find me:\nGardening — surprisingly decent at it actually. Plants generally survive under my watch, and it is a nice reminder that steady care and consistency usually pay off. Gaming — Rocket League when I want to compete, Overcooked when I want chaos, Red Dead Redemption when I want a story, Call of Duty when I want to rage quit. Variety keeps it interesting. Hiking — SA has decent trails. No signal, no work notifications, just walking. Cooking — experimental, occasionally edible. Same approach as debugging: try something, see what breaks, adjust. Reading — tech books, random Wikipedia deep dives, whatever catches my interest at 11pm. Get in Touch\r#\rGitHub: TGNkwane LinkedIn: tebogo-nkwane Email: nkwane.gerald@gmail.com CV (don\u0026rsquo;t be stingy): cvtebogonkwane.errors.co.za ","externalUrl":null,"permalink":"/about/","section":"About","summary":"Hey, I’m Tebogo Nkwane\r#\rI’m a backend engineer based in South Africa. I build large-scale systems, event-driven architectures, cloud infrastructure, and the kind of backend work that keeps things running at scale.\nThe Stack\r#\rWhat I know well:\nJava — Spring Boot, Spring Cloud Gateway, Spring WebFlux, Jakarta EE, the whole ecosystem AWS — Certified Developer. CDK, CloudFormation, Lambda, ELB, SNS, SQS, EventBridge, S3 and more Databases — PostgreSQL, DynamoDB, MySQL, DB2 DevOps — Docker, CI/CD pipelines, deployment as code What I’m learning:\n","title":"About","type":"about"}]