12. The standard library you reach for
Full examples: lessons/l12, with the C# side in csharp/L12.cs. CI runs both and compares their output with this lesson.
Four everyday APIs
Section titled “Four everyday APIs”The names change, but most concepts map one to one. The differences are in the defaults, and they are the subject of this lesson.
| C# | Java 25 |
|---|---|
DateTimeOffset in UTC |
Instant |
DateTimeOffset |
OffsetDateTime |
DateTime + TimeZoneInfo |
ZonedDateTime + ZoneId |
DateOnly, TimeOnly |
LocalDate, LocalTime |
DateTime with Kind Unspecified |
LocalDateTime |
TimeSpan |
Duration (time), Period (calendar) |
decimal |
BigDecimal |
File, Directory, Path |
Files, Path |
CultureInfo |
Locale |
HttpClient |
java.net.http.HttpClient |
System.Text.Json |
nothing in the JDK: a library such as Jackson |
Dates and times: java.time
Section titled “Dates and times: java.time”java.time arrived in Java 8 and replaced java.util.Date and Calendar. Its types are immutable and each one says what it holds: a point on the timeline, a date without a time, a time in a zone. C#’s DateTime covers several of those at once, told apart by its Kind.
// Instant is a point on the timeline, like a DateTimeOffset in UTC.Instant noon = Instant.parse("2026-09-13T12:00:00Z");ZoneId paris = ZoneId.of("Europe/Paris");System.out.println("in Paris: " + noon.atZone(paris));System.out.println("in Toronto: " + noon.atZone(ZoneId.of("America/Toronto")));
// LocalDate is DateOnly. Months count from 1, and an invalid date throws.LocalDate endOfJanuary = LocalDate.of(2026, 1, 31);System.out.println("a month after January 31: " + endOfJanuary.plusMonths(1));System.out.println("from January 31 to March 1: " + Period.between(endOfJanuary, LocalDate.of(2026, 3, 1)));try { LocalDate.of(2026, 2, 30);} catch (DateTimeException e) { System.out.println("DateTimeException: " + e.getMessage());}// The API it replaces counted months from 0.System.out.println("Calendar.SEPTEMBER: " + Calendar.SEPTEMBER);in Paris: 2026-09-13T14:00+02:00[Europe/Paris]in Toronto: 2026-09-13T08:00-04:00[America/Toronto]a month after January 31: 2026-02-28from January 31 to March 1: P1M1DDateTimeException: Invalid date 'FEBRUARY 30'Calendar.SEPTEMBER: 8plusMonths clamps to the end of the month, like DateOnly.AddMonths. Zone ids are the IANA names on every OS. .NET accepts them too since .NET 6, but on Windows only with ICU: the C# side of this course ran with InvariantGlobalization enabled, and on Windows FindSystemTimeZoneById("Europe/Paris") threw TimeZoneNotFoundException until I turned it off, as the documentation warns.
The old classes are still there, and still compile. new Date(126, 8, 13) is September 13, 2026, because the year counts from 1900 and the month from 0; javac only warns:
import java.util.Date;
class Legacy { // Year 2026 is written 126, and September is month 8. static Date lessonDay() { return new Date(126, 8, 13); }}LegacyDate.java:6: warning: [deprecation] Date(int,int,int) in Date has been deprecated return new Date(126, 8, 13); ^1 warningA day is not always 24 hours
Section titled “A day is not always 24 hours”Paris moves to summer time on March 29, 2026. A ZonedDateTime knows the zone’s rules, so “one day later” and “24 hours later” are different results:
// ZonedDateTime applies the zone's rules: Paris moves to summer time on March 29, 2026.ZonedDateTime saturdayNoon = ZonedDateTime.of(2026, 3, 28, 12, 0, 0, 0, paris);System.out.println("plusDays(1): " + saturdayNoon.plusDays(1));System.out.println("plusHours(24): " + saturdayNoon.plusHours(24));System.out.println("that day lasted " + Duration.between(saturdayNoon, saturdayNoon.plusDays(1)));
// A local time that doesn't exist is moved forward; one that happens twice takes the earlier offset.System.out.println("02:30 on March 29: " + ZonedDateTime.of(2026, 3, 29, 2, 30, 0, 0, paris));ZonedDateTime twice = ZonedDateTime.of(2026, 10, 25, 2, 30, 0, 0, paris);System.out.println("02:30 on October 25: " + twice + ", or " + twice.withLaterOffsetAtOverlap());plusDays(1): 2026-03-29T12:00+02:00[Europe/Paris]plusHours(24): 2026-03-29T13:00+02:00[Europe/Paris]that day lasted PT23H02:30 on March 29: 2026-03-29T03:30+02:00[Europe/Paris]02:30 on October 25: 2026-10-25T02:30+02:00[Europe/Paris], or 2026-10-25T02:30+01:00[Europe/Paris]C# has no type that combines a date, a time and a zone. A DateTimeOffset carries only an offset, so AddDays(1) keeps +01:00 and lands on 13:00 in Paris. The two transitions are handled differently too:
| On the C# side | Output |
|---|---|
saturdayNoon.AddDays(1) |
2026-03-29T12:00:00.0000000+01:00, in Paris: 2026-03-29T13:00:00.0000000+02:00 |
ConvertTimeToUtc of 02:30 on March 29 |
ArgumentException: The supplied DateTime represents an invalid time. |
GetUtcOffset of 02:30 on October 25 |
01:00:00, the standard offset |
So for a time that happens twice, Java picks the earlier offset (summer time) and .NET the standard one (winter time): the same local time maps to instants one hour apart.
Parsing and formatting
Section titled “Parsing and formatting”Each type parses only its own format. .NET’s DateTime.Parse("2026-09-13T12:00:00Z") succeeds and returns a DateTime of Kind Local, converted to the machine’s time zone. Java refuses to drop the Z:
// Each type parses only its own format.System.out.println("OffsetDateTime.parse: " + OffsetDateTime.parse("2026-09-13T14:00:00+02:00").toInstant());try { LocalDateTime.parse("2026-09-13T12:00:00Z");} catch (DateTimeParseException e) { System.out.println("DateTimeParseException: " + e.getMessage());}
// Y is the week-based year, and weeks depend on the locale.LocalDate newYearsEve = LocalDate.of(2026, 12, 31);System.out.println("yyyy: " + newYearsEve.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")));System.out.println("YYYY, US: " + newYearsEve.format(DateTimeFormatter.ofPattern("YYYY-MM-dd", Locale.US)));System.out.println("YYYY, France: " + newYearsEve.format(DateTimeFormatter.ofPattern("YYYY-MM-dd", Locale.FRANCE)));OffsetDateTime.parse: 2026-09-13T12:00:00ZDateTimeParseException: Text '2026-09-13T12:00:00Z' could not be parsed, unparsed text found at index 19yyyy: 2026-12-31YYYY, US: 2027-12-31YYYY, France: 2026-12-31The last lines are a classic bug. In DateTimeFormatter patterns, Y is the week-based year, and the builder’s documentation says it follows the locale’s week rules. In the US, the week that contains January 1 is week 1 of the new year, so December 31, 2026, a Thursday, is in 2027. France uses ISO weeks, where that week still belongs to 2026. The same pattern in .NET prints YYYY-12-31: Y isn’t a format specifier there, so it is copied as is. Use yyyy (or uuuu).
Money: BigDecimal
Section titled “Money: BigDecimal”decimal is a 128-bit value type with operators. BigDecimal is an immutable object with arbitrary precision, and methods instead of operators:
import java.math.BigDecimal;
class Invoice { BigDecimal total(BigDecimal net, BigDecimal tax) { return net + tax; }}DecimalOperators.java:5: error: bad operand types for binary operator '+' return net + tax; ^ first type: BigDecimal second type: BigDecimal1 errorA BigDecimal is an unscaled integer and a scale, the number of digits after the point: 2.00 is 200 with scale 2. decimal keeps a scale too, which is why 1.10m * 3 prints 3.30. The differences are in construction, equality, division and rounding:
// The double constructor keeps the binary approximation; valueOf and the string constructor don't.System.out.println("new BigDecimal(0.1): " + new BigDecimal(0.1));System.out.println("BigDecimal.valueOf(0.1): " + BigDecimal.valueOf(0.1));System.out.println("new BigDecimal(\"0.1\"): " + new BigDecimal("0.1"));
// Methods instead of operators. The scale is kept, as with decimal.System.out.println("10.50 + 0.5 = " + new BigDecimal("10.50").add(new BigDecimal("0.5")));System.out.println("1.10 * 3 = " + new BigDecimal("1.10").multiply(BigDecimal.valueOf(3)));
// equals compares the scale too; compareTo doesn't.var two = new BigDecimal("2.0");var twoPointZeroZero = new BigDecimal("2.00");System.out.println("2.0 equals 2.00: " + two.equals(twoPointZeroZero));System.out.println("2.0 compareTo 2.00: " + two.compareTo(twoPointZeroZero));System.out.println("HashSet size: " + new HashSet<>(List.of(two, twoPointZeroZero)).size());System.out.println("TreeSet size: " + new TreeSet<>(List.of(two, twoPointZeroZero)).size());new BigDecimal(0.1): 0.1000000000000000055511151231257827021181583404541015625BigDecimal.valueOf(0.1): 0.1new BigDecimal("0.1"): 0.110.50 + 0.5 = 11.001.10 * 3 = 3.302.0 equals 2.00: false2.0 compareTo 2.00: 0HashSet size: 2TreeSet size: 1new BigDecimal(0.1)is exact, and exactly wrong. It keeps every digit of thedoublenearest to 0.1. C#’s(decimal)0.1prints0.1, because the conversion rounds. Build from strings, or withBigDecimal.valueOf, which goes throughDouble.toString.equalscompares the scale. In C#,2.0m == 2.00mand2.0m.Equals(2.00m)are bothTrue, and aHashSet<decimal>keeps one of them. In Java,equalssaysfalse, so aHashSetkeeps two elements while aTreeSet, which usescompareTo, keeps one. Compare amounts withcompareTo, or normalise the scale before using them as keys.
Division and rounding have no defaults:
// Division needs a scale or a precision when the result doesn't terminate.try { BigDecimal.ONE.divide(BigDecimal.valueOf(3));} catch (ArithmeticException e) { System.out.println("ArithmeticException: " + e.getMessage());}System.out.println("1 / 3, scale 4: " + BigDecimal.ONE.divide(BigDecimal.valueOf(3), 4, RoundingMode.HALF_EVEN));System.out.println("1 / 3, DECIMAL128: " + BigDecimal.ONE.divide(BigDecimal.valueOf(3), MathContext.DECIMAL128));
// There is no default rounding mode.var price = new BigDecimal("2.345");try { price.setScale(2);} catch (ArithmeticException e) { System.out.println("ArithmeticException: " + e.getMessage());}System.out.println("HALF_EVEN: " + price.setScale(2, RoundingMode.HALF_EVEN) + ", HALF_UP: " + price.setScale(2, RoundingMode.HALF_UP));// Math.round on a double rounds halves up, towards positive infinity.System.out.println("Math.round(2.5): " + Math.round(2.5) + ", Math.round(-2.5): " + Math.round(-2.5));
// toString can switch to scientific notation.var thousand = new BigDecimal("1000.00").stripTrailingZeros();System.out.println("toString: " + thousand + ", toPlainString: " + thousand.toPlainString());ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.1 / 3, scale 4: 0.33331 / 3, DECIMAL128: 0.3333333333333333333333333333333333ArithmeticException: Rounding necessaryHALF_EVEN: 2.34, HALF_UP: 2.35Math.round(2.5): 3, Math.round(-2.5): -2toString: 1E+3, toPlainString: 1000C# answers each of these without an exception. 1m / 3m is 0.3333333333333333333333333333, rounded to fit the 28 to 29 significant digits of a decimal, and Math.Round(2.345m, 2) is 2.34, because Math.Round rounds halves to even unless you pass MidpointRounding.AwayFromZero. Java makes you choose: a scale and a RoundingMode for divide and setScale, or a MathContext, which sets a number of significant digits. HALF_EVEN is banker’s rounding, the .NET default. Math.round(2.5) on a double gives 3 where .NET’s Math.Round(2.5) gives 2; for -2.5 both give -2, Java because it rounds halves towards positive infinity, .NET because -2 is even.
The other way round, decimal can overflow (decimal.MaxValue + 1 throws OverflowException), while BigDecimal grows. And toString switches to scientific notation for a negative scale, among other cases, and a negative scale is what stripTrailingZeros produces on 1000.00: write toPlainString for anything a person reads.
Files and text: java.nio.file
Section titled “Files and text: java.nio.file”Files holds static methods like File and Directory together, and a Path is an object rather than a string:
// Files.writeString is File.WriteAllText; UTF-8 is the default charset since JDK 18.System.out.println("default charset: " + Charset.defaultCharset());Path notes = dir.resolve("notes.txt");Files.writeString(notes, "pen\npad\nink\n");System.out.println("readAllLines: " + Files.readAllLines(notes));
// Files.lines is File.ReadLines, but it holds the file open until the stream is closed.try (Stream<String> lines = Files.lines(notes)) { System.out.println("lines starting with p: " + lines.filter(line -> line.startsWith("p")).count());}default charset: UTF-8readAllLines: [pen, pad, ink]lines starting with p: 2dir.resolve("notes.txt") is Path.Combine. Before JDK 18, the default charset was the platform’s, often windows-1252 on Windows; JEP 400 made it UTF-8 everywhere, which is also .NET’s default for File.WriteAllText. JEP 400 kept one exception: System.out still encodes with the console’s charset, so a Java program that prints héllo in a Windows terminal can show h�llo while the file it wrote is correct.
Files.lines and Files.walk return lazy streams over an open file or directory handle, and must be closed. File.ReadLines closes its file when the foreach ends; a stream doesn’t have that hook, hence the try-with-resources.
Exceptions: checked and specific
Section titled “Exceptions: checked and specific”Almost every Files method declares IOException, which is checked (lesson 5), so this doesn’t compile:
import java.nio.file.Files;import java.nio.file.Path;
class Notes { static String read(Path path) { return Files.readString(path); }}ReadWithoutThrows.java:6: error: unreported exception IOException; must be caught or declared to be thrown return Files.readString(path); ^1 errorThe subclasses name the problem, and some cases that .NET accepts silently are errors:
// The exceptions are checked, and more specific than IOException.try { Files.readString(dir.resolve("missing.txt"));} catch (NoSuchFileException e) { System.out.println("NoSuchFileException: " + dir.relativize(Path.of(e.getFile())));}try { Files.createDirectory(dir);} catch (FileAlreadyExistsException e) { System.out.println("createDirectory: FileAlreadyExistsException");}Files.createDirectories(dir);System.out.println("createDirectories: no exception");try { Files.delete(dir);} catch (DirectoryNotEmptyException e) { System.out.println("delete: DirectoryNotEmptyException");}
// Invalid UTF-8: Files.readString throws, new String replaces the byte.Path broken = Files.write(dir.resolve("broken.txt"), new byte[] {'A', (byte) 0xFF, 'B'});try { Files.readString(broken);} catch (MalformedInputException e) { System.out.println("MalformedInputException: " + e.getMessage());}String replaced = new String(Files.readAllBytes(broken), StandardCharsets.UTF_8);System.out.println("new String: " + replaced.chars().mapToObj(c -> String.format("U+%04X", c)).toList());NoSuchFileException: missing.txtcreateDirectory: FileAlreadyExistsExceptioncreateDirectories: no exceptiondelete: DirectoryNotEmptyExceptionMalformedInputException: Input length = 1new String: [U+0041, U+FFFD, U+0042]| Case | C# | Java |
|---|---|---|
| missing file | FileNotFoundException |
NoSuchFileException, whose message is only the path |
| create an existing directory | Directory.CreateDirectory: nothing happens |
createDirectory throws; createDirectories doesn’t |
| delete a non-empty directory | IOException |
DirectoryNotEmptyException |
| invalid UTF-8 | File.ReadAllText replaces the byte with U+FFFD |
Files.readString throws MalformedInputException |
The last row matters for data pipelines: C# reads a corrupted file without complaint, Java stops at the first bad byte. Both behaviours are available on both sides; they are just not the default ones.
Culture and locale
Section titled “Culture and locale”String.format uses the default locale, as ToString uses CurrentCulture. A program that formats numbers without a locale prints 1234,50 on a French machine:
// String.format uses the default locale, like CurrentCulture; pass one to get a fixed result.System.out.println("France: " + String.format(Locale.FRANCE, "%.2f", 1234.5));System.out.println("ROOT: " + String.format(Locale.ROOT, "%.2f", 1234.5));System.out.println("Turkish lower case has a dotless i: " + "TITLE".toLowerCase(Locale.forLanguageTag("tr")).equals("tıtle"));France: 1234,50ROOT: 1234.50Turkish lower case has a dotless i: trueLocale.ROOT is CultureInfo.InvariantCulture. The Turkish test is the same trap in both languages: toLowerCase() and ToLower() without an argument use the current locale, and in Turkish the lower case of I is ı. Write toLowerCase(Locale.ROOT) for identifiers, file names and protocol keywords.
HTTP: java.net.http.HttpClient
Section titled “HTTP: java.net.http.HttpClient”Java 11 added java.net.http. Like .NET’s, the client is meant to be created once and reused, and it is AutoCloseable since Java 21. The example runs against HttpServer, a small server that ships with the JDK, so it needs no network; the C# side uses HttpListener.
// HttpClient is AutoCloseable since Java 21.try (HttpClient client = HttpClient.newHttpClient()) { System.out.println("follows redirects: " + client.followRedirects() + ", connect timeout: " + client.connectTimeout());
HttpResponse<String> old = client.send(HttpRequest.newBuilder(URI.create(base + "/old")).build(), HttpResponse.BodyHandlers.ofString()); System.out.println("GET /old: " + old.statusCode() + ", Location: " + old.headers().firstValue("Location").orElseThrow()); System.out.println("client version: " + client.version() + ", response version: " + old.version());
// An error status is a normal response, not an exception. HttpResponse<String> missing = client.send(HttpRequest.newBuilder(URI.create(base + "/missing")).build(), HttpResponse.BodyHandlers.ofString()); System.out.println("GET /missing: " + missing.statusCode());
// No timeout unless the request sets one. try { client.send(HttpRequest.newBuilder(URI.create(base + "/slow")).timeout(Duration.ofMillis(100)).build(), HttpResponse.BodyHandlers.ofString()); } catch (HttpTimeoutException e) { System.out.println("HttpTimeoutException: " + e.getMessage()); }}
// Following redirects is opt-in; sendAsync returns a CompletableFuture.try (HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build()) { HttpResponse<String> response = client.sendAsync(HttpRequest.newBuilder(URI.create(base + "/old")).build(), HttpResponse.BodyHandlers.ofString()).join(); System.out.println("GET /old, following redirects: " + response.statusCode() + " " + response.body() + " from " + response.uri().getPath());}follows redirects: NEVER, connect timeout: Optional.emptyGET /old: 302, Location: /newclient version: HTTP_2, response version: HTTP_1_1GET /missing: 404HttpTimeoutException: request timed outGET /old, following redirects: 200 moved here from /newThe C# side prints:
default timeout: 00:01:40GET /old: 200 moved here from /newGET /missing: 404GetStringAsync: HttpRequestException: Response status code does not indicate success: 404 (Not Found).TaskCanceledException (TimeoutException): The request was canceled due to the configured HttpClient.Timeout of 0.1 seconds elapsing.GET /old without redirects: 302, Location: /new| Default | .NET HttpClient |
Java HttpClient |
|---|---|---|
| redirects | followed (AllowAutoRedirect is true) |
not followed: Redirect.NEVER |
| timeout | 100 seconds | none: “block forever” |
| HTTP version | HTTP/1.1 | prefers HTTP/2, falls back to what the server speaks |
| error status | a response; GetStringAsync and EnsureSuccessStatusCode throw |
always a response |
| async | GetAsync returns a Task |
send blocks, sendAsync returns a CompletableFuture |
Two of these defaults bite in production. A Java client without a timeout waits forever on a server that accepts the connection and never answers; set timeout on each request, and connectTimeout on the builder. And code ported from C# that expects redirects to be followed gets a 302 with an empty body. Exercise 3 writes the missing GetStringAsync.
Blocking send is the normal choice on a virtual thread (lesson 9). sendAsync fits code that already composes CompletableFutures.
What the JDK doesn’t have
Section titled “What the JDK doesn’t have”The .NET base class library covers more ground than the JDK. There is no JSON API in JDK 25: java --list-modules shows java.net.http and jdk.httpserver, and nothing for JSON. Projects use Jackson, which Spring Boot configures by default, or Gson. There is no IHttpClientFactory or dependency injection either: those come from frameworks such as Spring, the next course.
Key takeaways
Section titled “Key takeaways”java.timehas one type per meaning:Instant,LocalDate,ZonedDateTime.ZonedDateTimeapplies time zone rules thatDateTimeOffsetdoesn’t know, and resolves gaps and overlaps instead of throwing.- In date patterns,
Yis the week-based year and depends on the locale; writeyyyy. - Build a
BigDecimalfrom a string orvalueOf, compare it withcompareTo, and give everydivideandsetScaleaRoundingMode. Filesmethods throw checked, specific exceptions, and reject invalid UTF-8 that .NET replaces. CloseFiles.linesandFiles.walk.- Pass a
LocaletoString.formatandtoLowerCasewherever the output is not for a person. - Java’s
HttpClientdoesn’t follow redirects and has no timeout by default; an error status is never an exception.
Exercises
Section titled “Exercises”- Write
addBusinessDays(LocalDate start, int days), which skips Saturdays and Sundays. Adding 1 business day to Friday, September 11, 2026 gives Monday the 14th; adding 5 gives Friday the 18th.
Solution
static LocalDate addBusinessDays(LocalDate start, int days) { LocalDate date = start; int added = 0; while (added < days) { date = date.plusDays(1); if (date.getDayOfWeek() != DayOfWeek.SATURDAY && date.getDayOfWeek() != DayOfWeek.SUNDAY) { added++; } } return date;}LocalDate is immutable, like DateOnly, so plusDays returns a new date that must be assigned. DayOfWeek is an enum, compared with != as in C#. The test also checks that adding 0 days returns the start date unchanged.
- Split a bill of
100.00between 3 people so that the shares add up to exactly the total:33.34,33.33,33.33. Writesplit(BigDecimal total, int people).
Solution
static List<BigDecimal> split(BigDecimal total, int people) { BigDecimal share = total.divide(BigDecimal.valueOf(people), 2, RoundingMode.DOWN); BigDecimal cent = new BigDecimal("0.01"); int extraCents = total.subtract(share.multiply(BigDecimal.valueOf(people))).divide(cent).intValueExact(); List<BigDecimal> shares = new ArrayList<>(); for (int i = 0; i < people; i++) { shares.add(i < extraCents ? share.add(cent) : share); } return shares;}Rounding each share with HALF_EVEN would give three times 33.33 and lose a cent. Rounding down, then handing out the remaining cents, keeps the sum exact. divide(cent) needs no rounding mode because the remainder is a whole number of cents, and intValueExact throws instead of truncating if that assumption is ever wrong. The test compares the sum with compareTo, not equals, for the scale reason above.
- Write
getString(HttpClient client, URI uri), the Java counterpart ofGetStringAsync: it returns the body, throws anIOExceptionwith the messageResponse status code does not indicate success: 404for a non-2xx status, and doesn’t wait more than 100 seconds. Test it against a localHttpServerwith a redirect and a 404.
Solution
static String getString(HttpClient client, URI uri) throws IOException, InterruptedException { HttpRequest request = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(100)).build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() < 200 || response.statusCode() > 299) { throw new IOException("Response status code does not indicate success: " + response.statusCode()); } return response.body();}The timeout goes on the request, where .NET puts it on the client. Redirects are a property of the client, so the test builds one with Redirect.NORMAL and checks that /old returns moved here, then that /missing throws with the expected message. IOException is the natural choice because send already declares it, so callers handle one exception type for network and status errors, as with HttpRequestException.