Coming to Rust from Java

What Changes

Rust shifts from Java's garbage collection to compile-time ownership rules and uses `cargo` for project management.

The first time you drop the JVM

You write a method that takes a String, pass it to a helper, then try to print it afterward. The compiler refuses to build. It tells you the value was moved. You reach for a try-catch block to handle a network timeout. The compiler tells you the function returns a Result, not an exception. You open your build file expecting dependency trees and plugin configurations. You find a single Cargo.toml with three lines.

Java trains you to trust the runtime. The garbage collector sweeps up forgotten objects. Exceptions bubble up until something catches them. Maven or Gradle assemble your classes into a JAR and ship it to a JVM. Rust trains you to trust the compiler. Memory is managed by explicit rules checked before the binary ever runs. Errors are values you handle or forward. The build tool compiles directly to machine code. The shift feels restrictive at first. It becomes liberating once you stop fighting the rules and start using them.

Ownership replaces the garbage collector

Java gives you a new keyword and expects you to forget about memory. The JVM runs a background thread that periodically scans for unreachable objects and frees them. That approach works well for high-level applications. It introduces pauses, unpredictable latency, and hidden complexity when you need deterministic cleanup.

Rust removes the background cleaner entirely. Every value has exactly one owner. When the owner goes out of scope, the value is dropped immediately. If you want another part of your code to use the value, you either move it or borrow it. Moving transfers ownership. Borrowing gives temporary access without taking responsibility for cleanup.

Think of a signed lease on a physical workspace. The person holding the lease decides when the space is vacated. If you want a colleague to use the room, you either hand them the lease (they become responsible for it) or you give them a temporary key (they can use it while you still hold the lease). The building manager never needs to guess who is still inside. The rules are explicit, and the cleanup happens the moment the lease expires.

This model eliminates entire classes of bugs. Null pointer exceptions disappear because the compiler tracks whether a value exists. Use-after-free errors vanish because borrowed references cannot outlive the data they point to. Data races are impossible because the compiler enforces exclusive or shared access at compile time.

A minimal server without the framework baggage

Java web development usually starts with a framework. Spring Boot, Jakarta EE, or Micronaut handle the boilerplate. Rust ships the networking primitives in the standard library. You can write a working TCP listener in under ten lines without pulling in external crates.

use std::net::TcpListener;
use std::io::Read;

/// Accepts incoming TCP connections and prints received bytes.
fn main() {
    // Bind to localhost on port 7878. Unwrap panics if the port is already taken.
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    
    // Iterate over each incoming connection. The iterator yields Results.
    for stream in listener.incoming() {
        // Extract the stream or skip this connection if it failed.
        let mut stream = match stream {
            Ok(s) => s,
            Err(e) => {
                eprintln!("Connection failed: {}", e);
                continue;
            }
        };
        
        // Read up to 1024 bytes from the client.
        let mut buffer = [0; 1024];
        let bytes_read = stream.read(&mut buffer).unwrap();
        println!("Received {} bytes", bytes_read);
    }
}

Run this with cargo run after scaffolding a project with cargo new my_project. The standard library handles socket creation, binding, and the accept loop. You handle the business logic. No XML configuration. No annotation processors. No classpath wars.

Trust the standard library. It is designed to be the foundation, not the bottleneck.

What happens under the hood

When you compile the server above, cargo invokes rustc. The compiler performs multiple passes. It checks types, verifies ownership, monomorphizes generics, and optimizes the code. The output is a single static binary containing everything it needs to run. No runtime is required on the target machine.

Java compilation works differently. javac produces bytecode. The JVM interprets or JIT-compiles that bytecode at runtime. The JVM also manages a heap, runs the garbage collector, and handles class loading. That abstraction layer adds flexibility but introduces overhead and deployment complexity.

Rust's cargo replaces Maven and Gradle. It manages dependencies, runs tests, builds documentation, and publishes crates. Dependency resolution uses a lockfile (Cargo.lock) that pins exact versions. The build process is deterministic. You get the same binary on your machine as on the CI server.

Convention aside: cargo fmt formats every file identically. The Rust community treats formatting as a solved problem. You save mental energy by not arguing about brace placement or indentation. You argue about logic instead.

Structs, traits, and the death of the class

Java relies on classes. A class bundles data and behavior. Inheritance creates rigid hierarchies. Interfaces define contracts but cannot hold state. Rust separates data and behavior. Structs hold data. Traits define behavior. You attach traits to structs using implementations.

/// Represents a user account with a name and an active status.
struct User {
    name: String,
    is_active: bool,
}

/// Defines a method for generating a display string.
trait Displayable {
    fn display(&self) -> String;
}

/// Implement the trait for User.
impl Displayable for User {
    fn display(&self) -> String {
        // Format the user data into a readable string.
        format!("User: {} (Active: {})", self.name, self.is_active)
    }
}

/// Provide a default greeting method for any type implementing Displayable.
impl<T: Displayable> T {
    fn greet(&self) -> String {
        // Combine the display string with a greeting.
        format!("Hello, {}", self.display())
    }
}

fn main() {
    let user = User {
        name: String::from("Alice"),
        is_active: true,
    };
    // Call the trait method and the blanket implementation.
    println!("{}", user.display());
    println!("{}", user.greet());
}

This pattern replaces deep inheritance trees. You compose behavior by implementing multiple traits on a single struct. A User can implement Displayable, Serializable, and Validatable without forcing it into a single parent class. The compiler verifies that every trait method exists and matches the signature.

Convention aside: #[derive(Debug)] is added to almost every struct during development. It generates a Debug implementation that prints field names and values. You remove it in production if you want to hide internal state, but keeping it during debugging saves hours of guesswork.

Error handling without exceptions

Java uses checked and unchecked exceptions. Checked exceptions force you to declare them in the method signature. Unchecked exceptions slip through until runtime. Both approaches mix control flow with error reporting. Rust treats errors as values. The Result<T, E> type wraps a success value or an error. Functions return Result explicitly. Callers decide whether to handle the error, forward it, or panic.

use std::fs;

/// Reads a file and returns its contents or an IO error.
fn read_config(path: &str) -> Result<String, std::io::Error> {
    // Attempt to read the file. The ? operator propagates errors upward.
    fs::read_to_string(path)
}

fn main() {
    // Handle the Result explicitly instead of catching an exception.
    match read_config("config.txt") {
        Ok(contents) => println!("Config loaded: {}", contents),
        Err(e) => eprintln!("Failed to load config: {}", e),
    }
}

The ? operator replaces nested try-catch blocks. It returns early if the value is an Err, otherwise it unwraps the Ok value. The compiler forces you to acknowledge every error path. You cannot accidentally ignore a failure.

If you forget to handle a Result, the compiler rejects you with E0308 (mismatched types) or E0277 (trait bound not satisfied). You cannot assign a Result<String, Error> to a String variable. The type system catches the mistake before it reaches production.

Convention aside: unwrap() is acceptable in main functions or tests where failure means the program cannot proceed. In library code, use expect("descriptive reason") or propagate the error with ?. The community treats silent unwrap() in shared code as a technical debt marker.

Picking your tools

Rust replaces Java patterns with explicit alternatives. You choose the right tool based on what you are building.

Use structs when you need to group related data without inheritance. Reach for traits when you want to define behavior that multiple types can share. Pick Result when a function can fail in a recoverable way. Use cargo when you want deterministic builds and a unified dependency manager. Reach for the standard library when you need networking, file I/O, or concurrency primitives without external dependencies. Use Option when a value might be absent instead of returning null. Pick Box<T> when you need heap allocation for a single value. Use Vec<T> when you need a growable array instead of ArrayList. Reach for HashMap when you need key-value lookups instead of HashMap or ConcurrentHashMap. Use lifetimes when you need to tie reference validity to data scope instead of relying on garbage collection.

The compiler enforces these choices. You cannot accidentally leak memory. You cannot accidentally ignore an error. You cannot accidentally create a data race. The restrictions feel tight until you realize they are guardrails, not walls.

Treat the borrow checker as a design partner. It will push you toward cleaner architecture.

Where to go next