Jump to content

What Learning Rust Taught Me About My Java Code

From JOHNWICK
Revision as of 00:57, 16 November 2025 by PC (talk | contribs) (Created page with "I didn’t switch teams.
I switched mental models. Rust didn’t make me abandon the JVM.
It made me delete a lot of Java habits that quietly cost latency, memory, and sleep. The punchline: ownership, explicitness, and resource discipline translate beautifully into modern Java (records, pattern matching, structured concurrency). And when they do, your “enterprise defaults” suddenly look… noisy. Pull Quote #1: “Rust didn’t replace Java for me; it replaced...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

I didn’t switch teams.
I switched mental models. Rust didn’t make me abandon the JVM.
It made me delete a lot of Java habits that quietly cost latency, memory, and sleep. The punchline: ownership, explicitness, and resource discipline translate beautifully into modern Java (records, pattern matching, structured concurrency). And when they do, your “enterprise defaults” suddenly look… noisy. Pull Quote #1: “Rust didn’t replace Java for me; it replaced excuses to keep writing magical, ambiguous Java.” 1) Ownership → Stop Sharing By Default Rust’s borrow checker screams when state might be mutated from two places. Java compiles it — and you debug it in production. So I started treating shared mutability as a code smell in Java:

  • Prefer records and List.copyOf to freeze aggregates.
  • Make fields final until you’re forced not to.
  • Return views, not backing collections.
  • Model state changes as explicit transitions, not silent setters.

Counterintuitive insight: Builders aren’t “safe” by default; they normalize mutability. Commit to immutable domain types and keep mutation at edges (parsers, caches, IO). Bold takeaway: If two threads can touch it, two engineers will eventually debug it. 2) Errors Are Data, Not An Afterthought Rust’s Result<T, E> forces you to model failure. In Java, “we’ll throw later” turns into catch-and-log-and-pray. What changed:

  • Domain errors got types (e.g., PaymentDeclined, InventoryShortage) and were handled at the boundary.
  • Exceptions were reserved for truly exceptional paths (corruption, invariants).
  • Service APIs returned “operation summaries” with success/failure fields that analytics could count.

Bold takeaway: If your metrics can’t count failures without parsing logs, your type system isn’t doing enough work. 3) Fearless Concurrency → Structured Concurrency Rust pushed me toward explicit lifetimes. In Java 21, that clicked with StructuredTaskScope. No more executor roulette, dangling futures, or “who cancels this?” threads. One change that paid rent (side-by-side requests with failure propagation): // Java 21+ record UserSummary(User user, List<Order> orders) {}

public UserSummary load(UUID userId) throws InterruptedException {

 try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
   var userTask = scope.fork(() ->
       userRepo.find(userId).orElseThrow(() -> new NoSuchElementException("user")));
   var ordersTask = scope.fork(() -> List.copyOf(orderRepo.findByUser(userId)));
   scope.join().throwIfFailed();        // propagate first failure, cancel siblings
   var user = userTask.get();
   var orders = ordersTask.get();       // already unmodifiable
   return new UserSummary(user, orders);
 }

} Why it’s Rust-ish:

  • Clear lifetime: tasks live only within the scope.
  • Failure is explicit: the first failure cancels peers.
  • No shared mutation: immutable results, safe handoff.

Pull Quote #2: “The opposite of ‘fearless concurrency’ isn’t fear; it’s ambiguity. Java 21 lets you delete the ambiguity.” 4) Allocation Discipline → Latency Discipline Rust makes you count allocations. The JVM lets you forget — until GC stalls remind you. Two policies changed my charts:

  • Kill accidental churn: ditch chained streams for hot paths; avoid ephemeral String concatenations; return List.copyOf once, not per access.
  • Flatten hot structs: fewer tiny objects; prefer records with primitive/compact fields.
  • Don’t cache lies: if the eviction policy is a guess, measure or remove it.

A quick micro-before/after on a read path (single node, same dataset): JMH 1.37, Java 21 Benchmark Mode Cnt Score Error Units load_user_orders_old thrpt 10 1.03 ± 0.03 ops/ms load_user_orders_scoped thrpt 10 1.72 ± 0.04 ops/ms

alloc.rate (avg) 38.2 MB/s -> 22.5 MB/s p95 latency 46.3 ms -> 28.1 ms Bold takeaway: Throughput that leaks memory isn’t throughput — it’s a timed error. 5) Lifetimes → Resource Boundaries You Can See Rust makes you obsess over “who owns this buffer?” Java lets streams, channels, and pools float around until someone gets paged. So I drew the boundaries — literally — and coded to them: ┌──────────┐ ┌──────────┐ ┌────────────┐ ┌──────────┐ │ Request │ --> │ Parse │ --> │ Validate │ --> │ Compute │ └──────────┘ └──────────┘ └────────────┘ └──────────┘

                     │                 │                  │
                     │ owns bytes      │ owns DTO         │ owns result
                     ▼                 ▼                  ▼
                (drop buffers)   (no leaks)         (persist & emit)
  • Buffers die after parse, not “later.”
  • DTOs don’t store raw references to mutable internals.
  • Results are copied into persistence models; no back-references.

If I can’t draw ownership on a whiteboard, I assume I don’t have it in code. 6) “Zero-Cost Abstractions” → Fewer Framework Apologies Rust pushed me to distrust “magic that disappears at runtime.” In Java, a lot of enterprise convenience isn’t free:

  • Reflection-heavy serialization that punishes hot paths.
  • Annotations that hide IO and retries behind proxies.
  • ORMs that perform politely wrong queries.

What changed: we kept DI for wiring, but pushed business logic toward plain records + services. For data, we favored jOOQ / hand-rolled SQL for read-heavy paths. Serialization moved to code-generated mappers where it mattered. Pull Quote #3: “If an annotation changes system behavior, it’s not documentation — it’s control flow.” 7) Exhaustiveness → Fewer Friday Surprises Rust makes the compiler yell when you forget a variant. Java 21’s sealed types + pattern matching get close:

  • Model PaymentState as a sealed interface (Pending, Authorized, Captured, Failed).
  • Handle them in a switch that the compiler checks for exhaustiveness.
  • Delete “default” fall-throughs that turn into silent no-ops.

Bold takeaway: If the compiler can’t prove you handled a state, you probably didn’t. 8) Cultural Shift → “Boring and Correct” Beats “Smart and Magical” Rust changed how I review Java:

  • I ask “where is the mutation?” and “who owns this?” on every diff.
  • I reject invisible retries and implicit timeouts unless they are logged and surfaced.
  • I push for observability by contract: every endpoint returns machine-countable outcomes.

And the weirdest side effect? On-call got quiet. The code looks plainer. The graphs look calmer. Contrarian Points I’ll Defend

  • Exceptions are fine when they model truly exceptional cases; don’t cosplay Result everywhere and drown the code in ceremony.
  • Caches should earn their keep with measurement; a cache that “probably helps” usually hides an unbounded memory risk.
  • Builders are for ergonomics, not safety; prefer immutable aggregates as your default.

What You Can Try This Week

  • Wrap one hot code path in StructuredTaskScope.ShutdownOnFailure. Track p95 and alloc rate.
  • Convert a mutable entity to a record and push mutation to a single factory/service.
  • Replace one magic annotation with explicit code and measure the difference.
  • Make one sealed hierarchy and remove a default from a switch—let the compiler guard you.
  • Draw the ownership diagram for a request path. Kill one leak.

The Arguable Finish Here’s the hill: Most Java pain isn’t because it’s Java. It’s because we write Java like we never learned ownership. If you disagree, show me a counterexample: a production system where more hidden mutability, more reflection, and more shared state outperformed a plain, explicit design with the same features and lower operational risk. Or — better — publish your audit.
What Rust habit, ported to Java, measurably moved your p95, alloc/s, or incident count? I’ll go first in the comments.
You bring charts. I’ll bring mine. Key Insights (for skimmers):

  • Ownership scales better than cleverness.
  • Structured concurrency deletes ambiguity, not just threads.
  • If you can’t count failures in metrics, your types are lying.