Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Object-Oriented Thinking in Rust

What you’ll learn: How Java’s object-oriented instincts map into Rust, what Rust keeps from classic OOP, what it rejects, and how to redesign Java service and domain models without forcing Rust into a class hierarchy.

Difficulty: 🟡 Intermediate

Java developers usually carry four strong OOP instincts:

  • bundle data and behavior together
  • reuse through inheritance
  • hide implementation behind interfaces
  • let frameworks create and wire object graphs

Rust agrees with some of that package, and flatly rejects the rest.

What Rust Keeps

Rust absolutely supports these object-oriented goals:

  • encapsulation
  • method syntax on user-defined types
  • interface-like abstraction through traits
  • polymorphism through generics and trait objects

So the right mental shift is not “Rust has no OOP.” The right shift is “Rust keeps the useful parts of OOP and drops the class-centric worldview.”

What Rust Rejects

Rust rejects several habits that Java developers often treat as default:

  • class inheritance as the main reuse mechanism
  • “everything is an object” as the core mental model
  • hidden ownership behind ambient references
  • framework-controlled object graphs as the normal source of structure

This is why Java-shaped Rust often feels awkward. The language is asking different design questions.

A Practical Translation Table

Java OOP habitBetter Rust direction
entity classstruct
service interfacetrait
abstract base classtrait plus helper struct or enum
field injectionexplicit constructor wiring
inheritance reusecomposition and delegation
nullable propertyOption<T>
checked or unchecked exceptionResult<T, E>

Encapsulation Still Exists

Encapsulation is alive and well in Rust:

#![allow(unused)]
fn main() {
pub struct Counter {
    value: u64,
}

impl Counter {
    pub fn new() -> Self {
        Self { value: 0 }
    }

    pub fn increment(&mut self) {
        self.value += 1;
    }

    pub fn value(&self) -> u64 {
        self.value
    }
}
}

The difference is that encapsulation is not built on a class hierarchy. Data and methods live together, but inheritance is not the glue.

Traits Are Interface-Like, Not Class-Like

Java developers usually meet traits and immediately ask whether they are just interfaces. The closest answer is “interface-like behavior plus stronger generic composition.”

#![allow(unused)]
fn main() {
trait PaymentGateway {
    fn charge(&self, cents: u64) -> Result<(), String>;
}

struct StripeGateway;

impl PaymentGateway for StripeGateway {
    fn charge(&self, cents: u64) -> Result<(), String> {
        println!("charging {cents}");
        Ok(())
    }
}
}

That gives interface-style abstraction, but Rust does not expect behavior sharing to be built around a base class.

Polymorphism Without Inheritance

Rust gives Java developers two main ways to express polymorphism.

Static dispatch with generics

Use this when the concrete implementation is known at compile time:

#![allow(unused)]
fn main() {
fn checkout<G: PaymentGateway>(gateway: &G, cents: u64) -> Result<(), String> {
    gateway.charge(cents)
}
}

Dynamic dispatch with trait objects

Use this when the implementation is selected at runtime:

#![allow(unused)]
fn main() {
fn checkout_dyn(gateway: &dyn PaymentGateway, cents: u64) -> Result<(), String> {
    gateway.charge(cents)
}
}

For Java developers, the important shift is that polymorphism is not automatically tied to a class hierarchy. Dispatch choice is explicit.

Composition Beats Inheritance

A lot of Java reuse patterns are really “I want to share capabilities” rather than “I need a deep base class.”

Java developers often begin here:

abstract class BaseService {
    protected final AuditClient auditClient;

    protected BaseService(AuditClient auditClient) {
        this.auditClient = auditClient;
    }

    protected void audit(String message) {
        auditClient.send(message);
    }
}

In Rust, that usually becomes composition:

#![allow(unused)]
fn main() {
struct AuditClient;

impl AuditClient {
    fn send(&self, message: &str) {
        println!("audit: {message}");
    }
}

struct UserService {
    audit: AuditClient,
}

impl UserService {
    fn create_user(&self, email: &str) {
        self.audit.send(&format!("create user {email}"));
    }
}
}

The behavior is shared because a field is shared, not because a parent class exists.

Closed Variation Often Wants enum

Java teams sometimes reach for abstract classes and interfaces even when the domain cases are fully known. Rust usually models that kind of variation with enum.

#![allow(unused)]
fn main() {
enum Notification {
    Email { address: String },
    Sms { number: String },
    Push { device_id: String },
}

fn send(notification: Notification) {
    match notification {
        Notification::Email { address } => println!("email {address}"),
        Notification::Sms { number } => println!("sms {number}"),
        Notification::Push { device_id } => println!("push {device_id}"),
    }
}
}

That is often better than a hierarchy because the compiler can enforce complete handling.

Service Design Without a DI Container

Spring and similar frameworks train Java developers to expect a container to wire everything together. Rust usually prefers constructors and explicit state:

#![allow(unused)]
fn main() {
struct UserRepository;
struct EmailClient;

struct UserService {
    repo: UserRepository,
    email: EmailClient,
}

impl UserService {
    fn new(repo: UserRepository, email: EmailClient) -> Self {
        Self { repo, email }
    }
}
}

That looks more manual at first, but it becomes much easier to read and debug because the dependency graph is plain code.

Better Questions for Java Developers

Instead of asking:

  • what is the base class?
  • where is the DI container?
  • which abstract service owns this behavior?

Ask:

  • who owns this data?
  • is this variation open or closed?
  • does this behavior need static or dynamic dispatch?
  • should this be a trait, a struct, or an enum?

Those questions fit Rust much better than classic OOP reflexes.

Common Java-to-Rust OOP Mistakes

  • rebuilding inheritance with unnecessary trait hierarchies
  • using trait objects everywhere, even when generics would be simpler
  • creating “manager” and “service” structs with vague ownership rules
  • hiding optional state in many nullable-like fields instead of using Option
  • expecting a framework to solve object graph design automatically

When Rust code starts looking like “Java without inheritance syntax,” the design usually needs another pass.

Final Thought

Rust does not ask Java developers to abandon abstraction, encapsulation, or polymorphism. It asks for better separation between:

  • data ownership
  • behavior abstraction
  • variation modeling
  • construction and wiring

Once those concerns stop being fused into “class design,” Rust becomes much easier to reason about.