โ† Back to blog
Object-Oriented Dart ยท Part 3 of 11
June 26, 202614 min read

Encapsulation in Dart: Privacy, Getters, Setters & Statics

DartOOPFlutter

Encapsulation in Dart

This is Part 3 of the Object-Oriented Dart series. We've built classes (Part 1) and learned every way to construct them (Part 2). Now we meet the first of OOP's four pillars: encapsulation.

Encapsulation is the discipline of hiding an object's internal details and exposing only a safe, deliberate surface. Think of a vending machine: you press buttons and money goes in, a drink comes out. You can't reach inside and rearrange the coils or short the wiring. The complexity is sealed away behind a small, well-defined interface. That's encapsulation, and Dart gives you clean tools for it.


Why bother hiding anything?

Imagine a BankAccount with a fully public balance:

class BankAccount {
  double balance = 0;
}

void main() {
  var acc = BankAccount();
  acc.balance = -9999; // ๐Ÿ˜ฑ nothing stopped this
}

Any code anywhere can set the balance to nonsense. There's no validation, no logging, no way to enforce "balance can't go negative." The object can't protect its own correctness โ€” its invariants (the rules that must always hold) are at the mercy of every caller.

Encapsulation fixes this by making the raw data private and forcing all access to go through methods (or getters/setters) that you control. The object becomes responsible for keeping itself valid.


Privacy in Dart: the underscore

Most languages use keywords like private and public. Dart doesn't. Instead it has one simple rule:

An identifier that starts with an underscore (_) is private to its library.

A "library" in Dart is, by default, a single .dart file (until you start using part/part of, which is rare). So _balance is visible everywhere inside the same file and invisible to any other file that imports it.

class BankAccount {
  double _balance = 0; // private โ€” not visible outside this file

  void deposit(double amount) {
    if (amount <= 0) throw ArgumentError('Deposit must be positive');
    _balance += amount;
  }

  double get balance => _balance; // read-only window
}

Now external code cannot write acc._balance = -9999 โ€” the field doesn't even exist as far as another file is concerned. The only ways in are deposit() and the read-only balance getter, both of which you control.

The "library, not class" gotcha. Privacy is per library (file), not per class. Two classes written in the same file can freely read each other's _private members. That surprises people coming from Java. It's also genuinely useful โ€” it lets a small cluster of tightly-related classes cooperate in one file while staying sealed off from the outside world.


Getters and setters: computed properties

You've already seen double get balance => _balance;. Let's name what that is.

A getter is a method that's called like a field โ€” no parentheses. A setter is the same idea for assignment. They let you expose a clean property-style surface while running real logic behind the scenes. The magic: you can start with a plain public field and later upgrade it to a getter/setter without changing a single line of caller code.

A read-only property

The simplest use is exposing private data for reading only โ€” provide a getter, no setter:

class Temperature {
  double _celsius;
  Temperature(this._celsius);

  double get celsius => _celsius;          // read-only
  double get fahrenheit => _celsius * 9 / 5 + 32; // fully computed
}

void main() {
  var t = Temperature(25);
  print(t.celsius);    // 25.0   โ€” looks like a field, is a getter
  print(t.fahrenheit); // 77.0   โ€” computed on the fly, stored nowhere
}

fahrenheit isn't stored anywhere โ€” it's derived every time you read it. Callers can't tell the difference between a stored field and a computed getter, and that's the point.

A validating setter

Pair a getter with a setter to intercept writes and enforce rules:

class Temperature {
  double _celsius;
  Temperature(this._celsius);

  double get celsius => _celsius;

  set celsius(double value) {
    if (value < -273.15) {
      throw ArgumentError('Below absolute zero!');
    }
    _celsius = value;
  }

  // A computed property with BOTH getter and setter:
  double get fahrenheit => _celsius * 9 / 5 + 32;
  set fahrenheit(double f) => _celsius = (f - 32) * 5 / 9;
}

void main() {
  var t = Temperature(25);
  t.fahrenheit = 212; // looks like assignment, runs conversion
  print(t.celsius);   // 100.0
}

Look at that last bit: writing t.fahrenheit = 212 feels like setting a field, but it actually runs your conversion and updates _celsius. The caller gets a clean, intuitive API and you keep full control. This is encapsulation at its most elegant.

Why not just use a method like setFahrenheit(212)? You could! Getters/setters are mostly about ergonomics โ€” they let a value read and write like a property (t.fahrenheit) instead of a function call (t.getFahrenheit()). Reach for a getter when something conceptually is a property; reach for a method when it conceptually does an action.


final and late fields

Encapsulation isn't only about privacy โ€” it's also about controlling when and whether a field can change.

final fields โ€” set once

A final field can be assigned exactly once, during construction, and never again. It's the simplest way to make part of an object immutable:

class Profile {
  final String username;     // can never change after construction
  final DateTime createdAt = DateTime.now(); // set at declaration

  Profile(this.username);
}

A final field gets an implicit getter but no setter โ€” so it's automatically read-only to the outside world, no underscore needed. Prefer final for anything that shouldn't change after an object is built; it eliminates a whole class of bugs.

late fields โ€” deferred initialization

Sometimes a non-nullable field genuinely can't be set at construction time โ€” maybe it depends on something computed later. Marking it late tells Dart "I promise to assign this before anyone reads it":

class Report {
  late final String _summary; // assigned later, but only once

  void build(List<String> lines) {
    _summary = lines.join('\n'); // first read of _summary is now valid
  }
}

late has two superpowers:

  1. It lets a non-nullable field skip initialization at the declaration/constructor โ€” Dart trusts you to set it first. (Read it before assigning and you get a clear runtime error, not a silent null.)
  2. late + an initializer = lazy evaluation. The initializer runs only on first access:
class Service {
  late final _connection = _expensiveSetup(); // runs on first use, not before
}

If nothing ever touches _connection, _expensiveSetup() never runs. Great for expensive resources you might not need.

Careful with late: it trades a compile-time guarantee for a runtime one. If you read a plain late field before assigning it, you get a LateInitializationError at runtime. Use it deliberately, not to silence the null checker.


Static members: state that belongs to the class

Everything so far has been instance state โ€” each object has its own copy. Sometimes, though, data or behavior belongs to the class itself, shared across all instances. That's what static is for.

Static variables (class-wide state)

class Player {
  static int count = 0; // ONE shared counter for the whole class
  final String name;

  Player(this.name) {
    count++; // every new player bumps the shared count
  }
}

void main() {
  Player('A');
  Player('B');
  print(Player.count); // 2 โ€” accessed on the CLASS, not an instance
}

There's exactly one count, no matter how many Players exist. You access it via the class name (Player.count), never via an instance. Static fields are also lazily initialized โ€” they don't exist until first used.

Static constants

static const is the idiomatic home for class-level constants:

class Circle {
  static const double pi = 3.14159;
  final double radius;
  Circle(this.radius);

  double get area => pi * radius * radius;
}

Static methods

A static method belongs to the class, not an instance โ€” so it has no this and can't touch instance fields. It's perfect for utility functions that relate to the type but don't need a particular object:

import 'dart:math';

class Point {
  final double x, y;
  const Point(this.x, this.y);

  // Doesn't need a specific point โ€” operates on two given points.
  static double distance(Point a, Point b) {
    final dx = a.x - b.x;
    final dy = a.y - b.y;
    return sqrt(dx * dx + dy * dy);
  }
}

void main() {
  final d = Point.distance(const Point(0, 0), const Point(3, 4));
  print(d); // 5.0
}

Instance vs. static, in one line: instance members need a specific object to make sense (order.describe() โ€” which order?). Static members make sense without any object (Point.distance(a, b), Player.count).


Putting it together: a self-protecting class

Here's a BankAccount that fully encapsulates its state โ€” invalid states are simply unreachable from outside:

class BankAccount {
  final String owner;
  double _balance;
  final List<String> _log = [];

  BankAccount(this.owner, {double openingBalance = 0})
      : _balance = openingBalance;

  double get balance => _balance;            // read-only
  List<String> get history => List.unmodifiable(_log); // defensive copy

  void deposit(double amount) {
    if (amount <= 0) throw ArgumentError('Deposit must be positive');
    _balance += amount;
    _log.add('Deposited $amount');
  }

  void withdraw(double amount) {
    if (amount <= 0) throw ArgumentError('Withdrawal must be positive');
    if (amount > _balance) throw StateError('Insufficient funds');
    _balance -= amount;
    _log.add('Withdrew $amount');
  }
}

Notice history returns List.unmodifiable(_log) โ€” a defensive copy. If we returned _log directly, a caller could do acc.history.clear() and erase our audit trail. Encapsulation isn't just about the fields; it's about not leaking mutable references to your internals either. This is a subtle, important habit.


Practice Challenges

Challenge 1 โ€” Make it private. Take this leaky class and encapsulate score so it can only be increased, never set directly or made negative.

class Game {
  int score = 0;
}
Show solution
class Game {
  int _score = 0;

  int get score => _score; // read-only

  void addPoints(int points) {
    if (points < 0) throw ArgumentError('points must be non-negative');
    _score += points;
  }
}

The underscore hides _score from other files; the getter offers read access; addPoints is the only mutator and it enforces the rule. There's no way to reach a negative score from outside.

Challenge 2 โ€” Computed getter. Add a Rectangle class with width and height, plus read-only area and perimeter getters.

Show solution
class Rectangle {
  final double width;
  final double height;
  const Rectangle(this.width, this.height);

  double get area => width * height;
  double get perimeter => 2 * (width + height);
}

void main() {
  const r = Rectangle(3, 4);
  print(r.area);      // 12.0
  print(r.perimeter); // 14.0
}

area and perimeter are derived on demand โ€” nothing is stored, and they read like plain properties.

Challenge 3 โ€” Getter + setter pair. Give a Person a private _age, a getter, and a setter that rejects negative ages.

Show solution
class Person {
  int _age;
  Person(this._age);

  int get age => _age;

  set age(int value) {
    if (value < 0) throw ArgumentError('age cannot be negative');
    _age = value;
  }
}

void main() {
  var p = Person(30);
  p.age = 31;        // goes through the setter
  print(p.age);      // 31
  // p.age = -5;     // would throw
}

The setter intercepts every assignment to age, so the validation can't be bypassed โ€” yet callers still use the natural p.age = 31 syntax.

Challenge 4 โ€” Static counter. Track how many Widget objects have been created, exposed as a read-only class-level count.

Show solution
class Widget {
  static int _count = 0;
  static int get count => _count;

  Widget() {
    _count++;
  }
}

void main() {
  Widget();
  Widget();
  Widget();
  print(Widget.count); // 3
}

_count is shared across all instances and private; the static getter count exposes it read-only on the class itself.

Challenge 5 โ€” Lazy late. Create a Config class whose settings map is loaded by an expensive function that should run only on first access, and never if settings is never read.

Show solution
class Config {
  late final Map<String, String> settings = _load();

  Map<String, String> _load() {
    print('Loading config...'); // proof it runs lazily
    return {'theme': 'dark'};
  }
}

void main() {
  var c = Config();
  print('Config created'); // "Loading config..." has NOT printed yet
  print(c.settings);        // now "Loading config..." prints, then the map
}

late final ... = _load() defers the initializer until the first read of settings. If you never touch settings, _load() never runs โ€” that's the lazy-evaluation superpower of late.

Challenge 6 โ€” Don't leak internals. A Team holds a private List<String> _members. Expose the members for reading without letting callers mutate the internal list.

Show solution
class Team {
  final List<String> _members = [];

  List<String> get members => List.unmodifiable(_members);

  void add(String name) => _members.add(name);
}

void main() {
  var t = Team()..add('Sam')..add('Alex');
  print(t.members);       // [Sam, Alex]
  // t.members.add('Eve'); // throws โ€” the returned list is unmodifiable
}

Returning _members directly would let outsiders mutate your internal state through the getter. List.unmodifiable hands back a read-only view, preserving encapsulation. (Those .. are cascades โ€” Part 4 covers them!)


Questions to test yourself

Q1 (basic). How do you make a field private in Dart?

Show answer

Prefix its name with an underscore (_balance). That makes it private to its library (by default, its file) โ€” invisible to any other file that imports the class.

Q2 (basic). What's the difference between a getter and a regular method?

Show answer

A getter is called like a property โ€” no parentheses (t.fahrenheit) โ€” and takes no arguments; it just returns a value. A regular method is called with parentheses (t.convert()) and can take arguments. Use a getter when something conceptually is a property; use a method when it does an action.

Q3 (intermediate). Two classes A and B are in the same .dart file. Can A read B's _secret field? What if they're in different files?

Show answer

Same file: yes โ€” Dart privacy is per library (file), not per class, so classes in the same file can see each other's underscore members. Different files: no โ€” _secret is invisible across library boundaries.

Q4 (intermediate). You expose a list via List<String> get tags => _tags;. Why is this a subtle encapsulation leak, and how do you fix it?

Show answer

It hands callers a reference to the actual internal list, so they can mutate your private state with obj.tags.add(...) or obj.tags.clear() โ€” bypassing all your controls. Fix it by returning a read-only view or copy: List.unmodifiable(_tags) (or List.of(_tags) for a mutable copy that doesn't affect the original).

Q5 (advanced). What's the practical difference between final and late final for a field, and what new failure mode does late introduce?

Show answer

A plain final field must be initialized at declaration or in the constructor โ€” the compiler guarantees it's set before use. late final lets you defer that single assignment to later (e.g., computed in a method), or run it lazily on first access. The trade-off: the guarantee moves from compile time to runtime โ€” reading a late field before it's assigned throws a LateInitializationError instead of being caught by the compiler.

Q6 (advanced). A static method can't access instance fields or this. Why not, and what does that tell you about when to make a method static?

Show answer

A static method belongs to the class, not to any particular instance, so there's no "current object" โ€” this is undefined and instance fields (which live on objects) aren't reachable. That means you should make a method static exactly when it doesn't need a specific instance to do its job: utility/helper functions that operate purely on their arguments or on static state (e.g., Point.distance(a, b)). If it needs an object's own data, it should be an instance method.


Wrapping up

Encapsulation is the habit that keeps objects trustworthy:

  • Underscore (_) makes members private to their library/file โ€” Dart's only access control.
  • Getters and setters expose a clean property surface while you keep control of reads, writes, and computed values.
  • final makes a field write-once (and read-only to the outside); late defers initialization and can make it lazy.
  • static members belong to the class itself โ€” shared state, constants, and utility methods with no this.
  • Don't leak mutable references to internals โ€” return unmodifiable views or copies.

In Part 4 we round out an object's surface with methods, operator overloading, and cascades โ€” including how to make == mean what you actually want it to mean.