C# Dev Exploring Java: How Do I Say That? (Part 3)
C# has over a dozen language features for concise API design. Java has fewer — and the gap shapes entire ecosystems. This is the biggest culture shock you’ll feel.
More from C# Dev Exploring Java
- C# Dev Exploring Java: Wait, It Does That? (Part 1)
- C# Dev Exploring Java: No Safety Net (Part 2)
- C# Dev Exploring Java: How Do I Say That? (Part 3) (Current)
- C# Dev Exploring Java: Generics, But Not Really (Part 4)
- C# Dev Exploring Java: Data & Async — The Twin Shocks (Part 5)
- C# Dev Exploring Java: The Effective Java Effect (Part 6)
Delegates vs Functional Interfaces — More Than Syntax
At first glance, C# delegates and Java lambdas look the same:
Func<string, int> length = s => s.Length;
Console.WriteLine(length("test")); // 4
Function<String, Integer> length = s -> s.length();
System.out.println(length.apply("test")); // 4
But delegates are not just syntax. They are a distinct runtime concept. A delegate is a callable value — it stores the method to call and the target object. A Java lambda is an object implementing a single-method interface.
Multicast Invocation
Delegates can represent multiple method calls at once:
Action a = () => Console.WriteLine("A");
Action b = () => Console.WriteLine("B");
Action combined = a + b;
combined(); // prints A, then B
Internally, the delegate holds an invocation list. Java functional interfaces can’t do this — you compose manually:
Runnable a = () -> System.out.println("A");
Runnable b = () -> System.out.println("B");
Runnable combined = () -> { a.run(); b.run(); };
Method Identity
Delegates know when two handlers refer to the same method target:
Action handler = () => Console.WriteLine("Hello");
Action ev = handler;
ev += handler;
ev -= handler;
ev(); // prints once
Java lambdas are anonymous objects — two identical lambdas are never equal:
() -> System.out.println("Hello") != () -> System.out.println("Hello")
This matters for subscription systems. Without identity, you can’t reliably unsubscribe.
Why This Matters
Delegates made behavior a lightweight value in C#. Java had to retrofit lambdas onto interfaces. The result: C# APIs treat behavior as something you pass inline; Java APIs lean on listener interfaces, strategy classes, and inheritance.
// C#: behavior as a parameter
void ProcessItems(IEnumerable<string> items, Action<string> handler)
{
foreach (var item in items) handler(item);
}
// Java: must reference a specific functional interface
void processItems(List<String> items, Consumer<String> handler) {
for (var item : items) handler.accept(item);
}
No Events — You Build the Pattern Yourself
C# events are a language feature backed by delegates:
class Button
{
public event Action Clicked;
public void Click() => Clicked?.Invoke();
}
button.Clicked += () => Console.WriteLine("Clicked!");
Java has no event keyword. Every event system is manually built with listener lists:
class Button {
interface ClickListener { void onClick(); }
private List<ClickListener> listeners = new ArrayList<>();
void addClickListener(ClickListener listener) {
listeners.add(listener);
}
void click() {
for (ClickListener l : listeners) l.onClick();
}
}
Spring provides ApplicationEvent / @EventListener — but it’s framework-dependent, not language-level.
Pitfall: What’s a one-liner in C# (event += handler) becomes interface definitions, registration methods, and manual iteration in Java.
No Properties — Getter/Setter Everywhere
C# auto-properties are a single line:
class Person {
public string Name { get; set; }
public int Age { get; set; }
}
Java requires explicit methods for every field:
class Person {
private String name;
private int age;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public int getAge() { return age; }
public void setAge(int age) { this.age = age; }
}
A C# class with 5 properties is 5 lines. In Java, it’s 15+ lines. This is the single biggest source of boilerplate — and the main reason Lombok exists.
Pitfall: You’ll instinctively type public int Age { get; set; } and wonder why the compiler won’t accept it. Java has no property syntax at all.
No Operator Overloading — Named Methods for Everything
C# lets you define natural syntax for custom types:
Vector sum = v1 + v2;
bool cmp = v1 < v2;
Celsius c = fahrenheit; // implicit conversion
int val = grid[2, 3]; // indexer
Java requires explicit method calls for all of these:
Vector sum = v1.add(v2);
int cmp = v1.compareTo(v2);
Celsius c = fahrenheit.toCelsius();
int val = grid.get(2, 3);
Java’s only exception is + for string concatenation.
Pitfall: Math-heavy code becomes harder to read. Fluent APIs are more verbose. Domain types lose the natural syntax you’re used to.
No Extension Methods — Utility Classes Everywhere
C# lets you add methods to existing types:
public static class StringExtensions
{
public static int WordCount(this string s) =>
s.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
}
text.WordCount(); // natural call style
Java forces static utility classes:
class StringUtils {
static int wordCount(String s) {
return s.trim().split("\\s+").length;
}
}
StringUtils.wordCount(text); // utility class call
Pitfall: APIs become less discoverable. IntelliSense shows fewer options. You rely on knowing which utility class to import.
No Anonymous Types — Explicit DTOs for Every Shape
C# lets you shape data inline:
var response = new { id = 1, name = "Abdullah", role = "admin" };
This is especially useful in API handlers with varying response shapes:
if (status == "error")
return BadRequest(new { errorMessage = "Something broke" });
return Ok(new { id = user.Id, name = user.Name });
Java requires a named class or record for every shape:
record ErrorResponse(String errorMessage) {}
record UserResponse(int id, String name) {}
if (status.equals("error"))
return ResponseEntity.badRequest().body(new ErrorResponse("Something broke"));
return ResponseEntity.ok(new UserResponse(user.id(), user.name()));
In practice, DTO classes multiply fast — ErrorResponse, UserSummaryResponse, AdminDetailResponse, PaginatedResponse, each in its own file. Workarounds (json nodes, maps, mega-DTOs) sacrifice type safety.
Pitfall: What C# keeps inline, Java scatters across files. The number of DTO classes can easily outnumber controllers.
No yield return — Manual Iterators for Every Sequence
C# generates state machines with yield return:
IEnumerable<int> Fib(int n)
{
int a = 0, b = 1;
for (int i = 0; i < n; i++)
{
yield return a;
(a, b) = (b, a + b);
}
}
Java requires a full Iterator<T> class:
class Fib implements Iterator<Integer> {
private int a = 0, b = 1, count;
Fib(int n) { this.count = n; }
@Override
public boolean hasNext() { return count > 0; }
@Override
public Integer next() {
int result = a;
int next = a + b;
a = b;
b = next;
count--;
return result;
}
}
The C# version is 8 lines. The Java version is a class with fields, constructor, and two methods — and you’re responsible for state tracking correctness.
Pitfall: Every custom lazy sequence requires a class. Libraries like Guava help, but can’t match compiler-generated state machines.
No nameof — String Literals That Break on Rename
C#’s nameof is compile-time safe:
ArgumentNullException.ThrowIfNull(person);
OnPropertyChanged(nameof(Name)); // survives rename
Java has no equivalent:
support.firePropertyChange("name", oldValue, newValue);
// rename the field? string still says "name"
Frameworks use -parameters compiler flag or annotation processors to approximate this, but neither is a language feature.
Pitfall: Every string is a potential stale reference. Refactoring requires manual updates across files.
Lambda Capture: Effectively Final
C# lambdas can capture and mutate outer variables:
int sum = 0;
list.ForEach(x => sum += x); // allowed
Console.WriteLine(sum); // 6
Java captured variables must be effectively final:
int sum = 0;
list.forEach(x -> sum += x); // compile error
Workarounds include AtomicInteger or switching to streams:
int sum = list.stream().mapToInt(Integer::intValue).sum();
Why Java Restricts This
Three reasons, each worth understanding:
1. Avoid hidden mutable closures. C# transforms captured variables into heap-allocated closure objects. Java avoided this to keep lambdas lightweight — often compiled to invokedynamic call sites without heap allocation.
2. Prevent concurrency footguns. Mutation in parallel streams would introduce race conditions silently. Java forces functional patterns that work safely in parallel.
// C#: no error, but race condition in parallel
Parallel.ForEach(list, x => { sum += x; });
// Java: won't even compile
list.parallelStream().forEach(x -> sum += x);
3. Encourage functional pipelines. Java’s design pushes you toward operations that are parallelizable without side effects.
int sum = list.parallelStream().mapToInt(Integer::intValue).sum();
Each missing feature pushes Java toward more ceremonial code. The same intent takes more files, more types, more methods. You learn to live with it, but the patterns you develop to cope — those become “the Java way.”
Coming up next: the one area where C# and Java diverged so deeply it affects every library you use.
More from C# Dev Exploring Java
- C# Dev Exploring Java: Wait, It Does That? (Part 1)
- C# Dev Exploring Java: No Safety Net (Part 2)
- C# Dev Exploring Java: How Do I Say That? (Part 3) (Current)
- C# Dev Exploring Java: Generics, But Not Really (Part 4)
- C# Dev Exploring Java: Data & Async — The Twin Shocks (Part 5)
- C# Dev Exploring Java: The Effective Java Effect (Part 6)