C# Dev Exploring Java: The Effective Java Effect (Part 6)
Every difference in Parts 1-5 shares a common thread. Not age. Not design by committee. Something else — a book that became a blueprint for an entire ecosystem.
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)
- 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) (Current)
The Patterns Behind the Patterns
Static factories instead of constructors. Builders everywhere. Immutability by default. Optional<T> instead of nullables. Composition over inheritance. Each of these patterns appears in nearly every Java API you touched in this series.
They didn’t emerge by accident. They trace back to Effective Java by Joshua Bloch — a book so influential it shaped how an entire language designs APIs. Many Java developers consider it required reading.
“Required reading” has consequences. When a book’s patterns become the default way to write APIs, they stop being one option and start being the only way.
Let’s walk through them.
Static Factory Methods — Why new Isn’t Everywhere
C# APIs use constructors directly:
var client = new HttpClient();
var uri = new Uri("https://example.com");
Java APIs hide constructors behind static factories:
HttpClient client = HttpClient.newHttpClient();
URI uri = URI.create("https://example.com");
The pattern gives you naming, caching control, and immutability guarantees. But it also means every library has a different creation idiom. newBuilder()? of()? newInstance()? create()? You learn per-framework, not per-language.
.NET has static factories too (Task.Run, Task.Factory.StartNew), but they’re the exception, not the rule. Constructors remain the default.
Builder Pattern — Why Object Initializers Don’t Exist
C# uses object initializers:
var person = new Person { Name = "Abdullah", Age = 23 };
Java uses builders:
Person person = new Person.Builder()
.name("Abdullah")
.age(23)
.build();
Builders enable immutability and optional fields with fluent syntax. They also add a whole extra class to maintain. Every Java project has builder classes — either hand-written, Lombok-generated, or IDE-generated.
C# solved the same problem with a language feature: { get; init; }, required members, and with expressions. No builder needed.
Immutability by Default — Why List.of() Throws
C# lists are mutable. Immutability is opt-in:
var list = new List<string> { "a", "b", "c" };
list.Add("d"); // works
var immutable = ImmutableList.Create("a", "b", "c"); // explicit choice
Java favors immutability as the default:
List<String> list = List.of("a", "b", "c"); // immutable
list.add("d"); // throws UnsupportedOperationException
The pattern encourages safer APIs. The cost: surprise exceptions when C# devs expect mutable lists. And every time you do need mutation, you copy.
Composition Over Inheritance — Why APIs Feel Fragmented
C# embraces structured inheritance — FileStream, MemoryStream, NetworkStream all inherit from Stream. It’s clean polymorphism.
Java APIs favor small, composable objects wired together:
class Logger {
private final Writer writer;
Logger(Writer writer) { this.writer = writer; }
void log(String message) { writer.write(message); }
}
Instead of class Logger extends BaseLogger, you compose Logger with a Writer. This pattern appears everywhere: Executor, HttpClient, Stream pipelines.
The result: Java APIs feel more fragmented — more small types, more wiring. But each piece is independently testable and replaceable.
| Ecosystem | Default instinct |
|---|---|
| Java | Composition first |
| C# | Inheritance is acceptable |
The Divergence Table
Here’s the pattern that emerges. Same design idea, two completely different implementations:
| Idea | Java solution | C# solution |
|---|---|---|
| Immutable objects | List.of(), builders |
record, init setters, collection expressions |
| Null safety | Optional<T> (library class) |
string?, ??, ??= (language features) |
| Concise DTOs | Lombok @Data (annotation processor) |
record (language built-in) |
| Static creation | Factory methods everywhere | new + initializers; factories only when needed |
| Composition | Small interfaces, manual wiring | Delegates, mixins, default interface methods |
| Builder pattern | Dedicated Builder class | with expressions, required members, named args |
Java’s column is all patterns and libraries. C#’s column is all language features.
This is the core divergence. Java took the ideas from Effective Java and encoded them as patterns you must implement, frameworks you must learn, and conventions you must follow. C# took the same ideas and built language features so you don’t need the patterns.
Lombok as the Smoking Gun
Look at Lombok. It’s a compile-time annotation processor that generates getters, setters, constructors, builders, equals, hashCode, and toString:
import lombok.Data;
@Data
class Person {
private String name;
private int age;
}
That @Data generates ~50 lines of boilerplate. C# developers get all of this for free with record.
Lombok exists because Java’s language won’t evolve in this direction. It’s an annotation processor compensating for missing language features. It introduces IDE plugins, build tool configuration, and debugging friction — all to do what C# does natively.
Even static class — a language concept in C# — requires a pattern in Java:
static class StringUtils { // language-level
public static int WordCount(string s) => s.Split().Length;
}
final class StringUtils { // manually enforced
private StringUtils() {} // prevent instantiation
public static int wordCount(String s) { ... }
}
Lombok’s @UtilityClass automates this — more evidence that patterns fill gaps the language won’t.
Build System Complexity as Consequence
When your language can’t express much, your build system does everything.
C# builds are straightforward:
dotnet build
Java builds require understanding dependency scopes, plugin lifecycles, annotation processing, and multi-module interactions:
mvn clean install # or gradle build
Annotation processing (how Lombok works, how MapStruct works, how Dagger works) is a build system feature, not a language feature. Code generation is configured in XML or Groovy, not expressed in code.
C# developers coming to Java underestimate this learning curve. The language is only part of the story; the build system is where the real complexity lives.
Classpath / Dependency Hell
C# assembly resolution is usually predictable. Version conflicts are managed by binding redirects and NuGet’s dependency resolution.
Java’s classpath model produces runtime errors that compile fine:
ClassNotFoundException
NoSuchMethodError
NoClassDefFoundError
These happen when different versions of the same library appear on the classpath, or transitive dependencies conflict. The code compiles. The IDE shows no errors. Then it crashes at runtime.
Annotations vs Attributes
C# attributes exist but are typically sparse — markers for serialization, deprecation, or metadata:
[Serializable]
public class Example
{
[Obsolete("Use NewMethod instead")]
public void OldMethod() { }
}
Java annotations drive entire frameworks. Spring alone uses @Service, @Transactional, @Autowired, @Component, @Configuration, @RestController, and dozens more — each one injecting behavior through reflection:
@Service
@Transactional
@RequiredArgsConstructor
class ExampleService {
public void doWork() { }
}
The difference: C# frameworks configure through code; Java frameworks configure through annotations. Annotations make behavior implicit — which feels magical until you need to debug it.
Same Wound, Two Bandages
Effective Java wasn’t wrong. It was brilliant. It taught a generation how to design robust APIs in a language that couldn’t absorb its own lessons into syntax.
Java took the lessons and built more patterns, more frameworks, more build plugins — because that’s the tool it had. C# was evolving in parallel and built language features so you don’t need the patterns at all.
The result isn’t that one is better. The result is that they diverged. Two ecosystems that read the same book, solved the same problems, and arrived at profoundly different answers — not because the problems were different, but because the languages were.
If you take one thing from this series: the differences aren’t random. They are the logical outcome of a language that chose backward compatibility over evolution, and patterns over features. Understanding why Java looks the way it does makes the “wait what” moments feel less like bugs and more like philosophy.
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)
- 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) (Current)