Skip to main content
CleanCodeMastery

Remove Setting Method: Some Things Are Written in Ink, Not Pencil

Remove Setting Method explained simply — why a field that should never change after construction must not have a setter, and how read-only fields, init-only properties, and records turn 'please don't change this' into a compiler guarantee.

25 min read Updated June 11, 2026beginner
refactoringremove setting methodimmutabilityreadonlyinit-onlyrecordssetterstypescriptcsharp

🖋️ The Birth Certificate With No Pen Attached

When Ananya was born, her parents — Lakshmi and Raghav — went to the municipal office in Chennai. Mr. Kannan, the registrar, was a careful man. He checked the hospital slip twice, wrote Ananya's name and her date of birth into the big register, printed the certificate, pressed the brass stamp on it, and handed it over with both hands, like it was something precious. Because it was.

Now notice something important about that certificate. It does not come with a pen. There is no little box saying "to change the date of birth, write the new date here." The date was written once, at the moment of the event, by the one person authorised to write it — and the whole system is designed so it can never be casually changed afterwards. Why? Because a date of birth is not an opinion that updates — it is a fact, fixed at one moment in time. School admission, passport, voting age, Ananya's first cricket trials — so many things will be built on top of that one date that allowing anyone with a pen to "update" it would break trust in everything above.

Years later, Ananya's school sports coach actually tried. Ananya was three months too old for the under-12 team, and the coach said, half-joking, "Just get the certificate adjusted, no?" Raghav laughed and asked him to imagine the opposite world: a certificate where any coach, any clerk, any uncle with a ballpoint pen could change the date. Within a year, nobody — no school, no passport office — would trust any certificate at all. The whole value of the document comes from the missing pen.

Compare that with the classroom attendance register lying open on the table, where any child passing by can flip a P to an A. Within a week, nobody trusts that register either.

Software has its own birth-certificate facts: an account's ID, an order number, a ticket's PNR, the timestamp when a record was created. These values are set once, at the object's "birth" — its constructor. Yet very often, out of pure habit, we give every field a public setter — we staple a pen to the certificate. The refactoring that removes the pen is called Remove Setting Method.

Figure 1: Removing the pen — the value enters once at birth, then the compiler stands guard

🧠 What is Remove Setting Method?

Remove Setting Method means deleting the setter for a field whose value should be decided once, at construction, and never changed again. The value goes in through the constructor; after that, the field is read-only.

Before — the certificate with a pen stapled to it:

class Account {
  private _id: string;
 
  constructor(id: string) {
    this._id = id;
  }
 
  get id(): string { return this._id; }
 
  set id(value: string) {        // why does this even exist?
    this._id = value;
  }
}
 
// ...much later, in a faraway file:
account.id = "TEMP-FIX-42";      // identity silently corrupted

After — written in ink:

class Account {
  constructor(readonly id: string) {}   // set at birth, fixed forever
}
 
// account.id = "TEMP-FIX-42";   // compile error — there is no pen

The deep idea here is the difference between a convention and a guarantee. A code comment saying // please never change id is a convention — it works until one tired developer at 7 pm changes it anyway. Removing the setter turns the rule into a guarantee enforced by the compiler. Nobody can break the rule, because the language itself refuses. Mr. Kannan does not have to trust every coach in Chennai to behave; the certificate simply has no place to write.

Fowler's catalog gives this refactoring a one-line summary that says it all: if you do not want a field to change once the object is created, do not provide a setting method. The Refactoring Guru entry adds the practical trigger: the value is meant to be set only at creation, yet the setter keeps inviting "updates" from anywhere in the program.

Here is the conversation between code and object, before and after — notice where the guarantee comes from:

Figure 2: Birth through the constructor; every later write meets a closed door
💡

A quick sorting question for every field you design: is this value written in ink or in pencil? Ink: identity, creation time, the defining facts of the object — constructor only, no setter. Pencil: things that genuinely change during life — but even then, prefer an honest method like markDelivered() over a raw set status. If you cannot decide, start with ink: it is far easier to add a setter later than to remove one the whole codebase has started using.

🔍 When do we need it?

Look for these signs:

  1. A setter that is called only once, right after new. Search for usages. If every caller does const t = new Ticket(); t.pnr = "8642097531";, the value clearly belongs in the constructor, and the setter is just a slower, riskier constructor.
  2. Identity and birth facts with setters. id, orderNumber, pnr, createdAt, admissionNo — values that define the object. A changeable identity is a contradiction, like a changeable date of birth.
  3. "Who changed this?" debugging sessions. A bug report says the order's customer ID differs from what was saved. With a public setter, every line in the program is a suspect. Remove the setter, and the question cannot even arise.
  4. Auto-generated setters from habit. Many classes carry a get/set pair for every field only because an IDE template produced them. Each unnecessary setter widens the class's mutable surface for no benefit — and the construction ritual it encourages (new followed by six setter calls in the right order) is really a Long Parameter List problem wearing a disguise: the constructor's job has been smeared across six fragile steps.
  5. Freshly created parameter objects. When you cure Data Clumps by introducing a parameter object such as Address or DateRange, that object gets shared between methods and callers. If it has setters, one method can mutate it while another still holds a reference — a classic aliasing bug. Parameter objects should be born without pens.

When is it not needed? When the value genuinely changes during the object's life. A student's marks this term, a delivery's current location, a game's score — these are pencil values. For them, controlled mutation is correct; just make it an intention-revealing method rather than a naked setter. Remove Setting Method is for ink values only.

If you audit a typical business class honestly, the split usually surprises people — most fields never legitimately change at all:

Figure 3: A typical entity, audited field by field

More than half the fields were ink wearing pencil costumes, and a fifth should not have been stored fields at all — they were derived values (totalWithTax) pretending to be facts. Only a quarter genuinely needed any mutation path.

A quick chart to sort any field you are unsure about — how often it truly changes versus how much of the system depends on it:

Figure 4: Should this field lose its setter?

Reading it: orderId sits deep in "Remove setter now" — never changes, everything depends on it. status changes often and everything depends on it — it keeps a mutation path, but a guarded, named one. A draft note that only one screen reads? A plain setter will not hurt anyone.

FieldInk or pencil?Correct door
admissionNo, pnr, orderIdInkConstructor only
dateOfBirth, createdAt, admittedOnInkConstructor only
status, currentClass, seatPencilNamed method with rules (promoteToNextClass())
score, feePaidPencilNamed method (addPoints(), recordFeePayment())
totalWithTax, ageNeither — derivedCompute in a getter; store nothing

🪄 Before and after at a glance

Before — every field stays editable forever:

// BEFORE: a railway ticket where everything is changeable, always
class TrainTicket {
  pnr = "";
  passengerName = "";
  trainNo = "";
  journeyDate = "";
  seat = "";
}
 
const ticket = new TrainTicket();
ticket.pnr = "8642097531";         // four-step ritual just to be born
ticket.passengerName = "Meera";
ticket.trainNo = "12626";
ticket.journeyDate = "2026-07-15";
 
// ...weeks later, in some helper:
ticket.pnr = "0000000000";         // the ticket's identity, gone
ticket.journeyDate = "2026-07-99"; // nonsense, silently accepted

After — ink fields fixed at construction; only the genuinely changing value keeps a controlled path:

// AFTER: ink for identity, a guarded pencil only where life requires it
class TrainTicket {
  private _seat: string;
 
  constructor(
    readonly pnr: string,            // ink
    readonly passengerName: string,  // ink
    readonly trainNo: string,        // ink
    readonly journeyDate: string,    // ink
    seat: string,
  ) {
    this._seat = seat;               // pencil — but guarded below
  }
 
  get seat(): string { return this._seat; }
 
  reallocateSeat(newSeat: string): void {   // honest name, one entry point
    if (!/^\d{1,2}[A-F]$/.test(newSeat)) throw new Error("Invalid seat");
    this._seat = newSeat;
  }
}
 
const ticket = new TrainTicket("8642097531", "Meera", "12626", "2026-07-15", "32B");
// ticket.pnr = "0000000000";      // compile error: read-only
ticket.reallocateSeat("41C");      // allowed, validated, searchable

Two improvements at once. Construction became one honest step instead of a ritual of setter calls — the object is complete and valid from its first breath. And the only changing value, the seat, now changes through one named, validated door.

The object's whole life, drawn as states — there is exactly one moment when ink can be written, and it is the birth moment:

Figure 5: One writable moment at birth; afterwards only named, guarded doors

🪜 Step-by-step, the safe way

Removing a setter sounds like one keystroke, but doing it safely on a living codebase needs care. Here is the slow, sure recipe — the registrar's procedure, in code.

Step 1: Confirm the field is truly ink. Read the class, think about the domain. Is this value really fixed after creation, or does some legitimate business flow change it? If a real flow changes it, stop — this refactoring is not for that field.

Step 2: Find every caller of the setter. Use Find Usages. Sort the results into two piles: calls that happen at creation time (right after new), and calls that happen later in life. The first pile will move into the constructor. Each item in the second pile is either a bug you have just discovered, or proof that the field is pencil after all.

Step 3: Make sure the constructor can receive the value. If it does not yet take this value, add a parameter for it. During the transition, both roads exist:

// Intermediate state: constructor takes the value, setter still alive
class Account {
  private _id: string;
 
  constructor(id: string) {
    this._id = id;
  }
 
  get id(): string { return this._id; }
  set id(value: string) { this._id = value; }   // still here — for one more step
}

Step 4: Migrate creation-time setter calls into constructor arguments, one call site at a time.

// before:
const a = new Account("");
a.id = "ACC-1009";
 
// after:
const a = new Account("ACC-1009");

Compile and test after each migration. Boring, safe, fast.

Step 5: Delete the setter and make the field read-only.

class Account {
  constructor(readonly id: string) {}
}

Now compile. This is the beautiful moment: the compiler becomes your detective. Every remaining assignment anywhere in the codebase lights up as a red error — a complete, guaranteed list of every place that was still scribbling on the certificate. Route each one through construction instead.

Step 6: Run all tests — especially anything touching frameworks. Serializers, ORMs, and model binders sometimes fill objects through setters behind the scenes; the next callout explains the escape routes.

The payoff curve is real. Here is what one team measured across a quarter as they removed setters from their core entities — the metric is hours per month spent on "who changed this value?" debugging hunts:

Figure 6: Mystery-mutation debugging time as setters were removed

The last sliver never quite reaches zero — pencil fields still exist — but every locked field permanently retires a whole category of bug. A value that cannot change cannot have been changed wrongly.

⚠️

Frameworks are the one genuine trap. Some ORMs, JSON deserializers, and form binders expect a parameterless constructor plus settable properties. Before deleting setters on a persisted or serialized class, check your framework's options: EF Core and System.Text.Json both support constructor binding, and C# init accessors let deserialization set values during creation while blocking everything afterwards. The right answer is almost never "keep the public setter"; it is usually "use constructor binding or an init accessor." Also remember: on a published library, removing a public setter is a breaking API change — schedule it with a proper version bump.

🏗️ A bigger real-life example

A school management system in Pune tracks student records. The original class is all pencil — and the bugs show it:

// BEFORE: everything editable, forever, by anyone
class StudentRecord {
  admissionNo = "";
  dateOfBirth = "";
  admittedOn = "";
  currentClass = 6;
  feePaidPaise = 0;
}
 
// Found scattered across the codebase:
record.dateOfBirth = "2014-03-30";   // "small correction" before sports trials
record.admissionNo = "PNE-NEW-77";   // duplicate-fix script rewrote identities
record.admittedOn = "2026-04-01";    // backdated to dodge a late-admission fee
record.currentClass = 8;             // skipped class 7 entirely — typo? fraud?

Every one of these lines compiled happily. The first one is Ananya's sports coach with a ballpoint pen — except in this school's software, the pen actually worked. The system had rules in people's heads — "date of birth never changes", "admission number is permanent" — but the code enforced none of them. Now we remove the pens:

// AFTER: ink facts locked at admission; life changes go through named doors
class StudentRecord {
  private _currentClass: number;
  private _feePaidPaise = 0;
 
  constructor(
    readonly admissionNo: string,   // identity — ink
    readonly dateOfBirth: string,   // birth fact — ink
    readonly admittedOn: string,    // history fact — ink
    startingClass: number,
  ) {
    if (!/^PNE-\d{4}$/.test(admissionNo)) throw new Error("Bad admission number");
    this._currentClass = startingClass;
  }
 
  get currentClass(): number { return this._currentClass; }
  get feePaidPaise(): number { return this._feePaidPaise; }
 
  promoteToNextClass(): void {              // the ONLY way class changes
    if (this._currentClass >= 12) throw new Error("Already in final class");
    this._currentClass += 1;                // always exactly +1 — no skipping
  }
 
  recordFeePayment(paise: number): void {
    if (paise <= 0) throw new Error("Payment must be positive");
    this._feePaidPaise += paise;
  }
}

Walk through what each removal bought us:

  • dateOfBirth, admissionNo, admittedOn are now facts, not variables. The sports-trials "correction" and the identity-rewriting script are compile errors today. If a certificate was genuinely entered wrong, the school issues a new corrected record — a deliberate, visible act by an authorised person, exactly like Mr. Kannan's office reissuing a certificate — instead of silently overwriting history.
  • currentClass is pencil, but the pencil is guarded: promoteToNextClass() moves exactly one class up. The "skipped class 7" bug is unrepresentable.
  • Money flows only through recordFeePayment(), which refuses nonsense.

The final shape, as a class diagram — count the doors:

Figure 7: Three ink fields with no doors; two pencil fields behind named, guarded doors

And here is the immutability bonus that beginners often miss: this object is now safe to share. The reports module, the ID-card printer, and the fees module can all hold the same StudentRecord without fear, because none of them can spoil the ink fields for the others. No defensive copying, no "who changed this?" hunts.

College corner — immutability and thread safety: the sharing argument becomes life-or-death the moment threads enter the picture. A mutable object shared between two threads needs locks, memory barriers, and prayer: thread A may read currentClass halfway through thread B writing it, and on modern CPUs each core's cache can hold a stale copy of a mutable field unless you pay for synchronisation. An immutable object needs none of that. Since no field can change after construction, there is no write to race against — every thread forever sees the same values, no locks required. This is why functional languages default to immutability, why String is immutable in Java and C#, and why concurrency guides repeat the mantra: share immutable data freely; share mutable data carefully or not at all. One subtlety worth knowing: the object must be safely published — fully constructed before its reference is shared — which is exactly what "all values in through the constructor" gives you for free. An object built by new plus six setter calls has a window of half-built visibility; a constructor-built immutable object does not.

Here is what daily life looks like for the people using the system, before and after the pens were removed:

Figure 8: A month in the school office, before and after locking the ink fields

⚙️ The same refactoring in C#

C# is wonderfully equipped for this refactoring — it offers a whole ladder of immutability, so you can pick exactly the rung you need.

Rung 1: get-only auto-property. The classic Remove Setting Method. Assignable in the constructor, locked everywhere else:

public class Account
{
    public string Id { get; }              // no setter at all
 
    public Account(string id) => Id = id;
}
 
// account.Id = "oops";                    // CS0200: read-only

Rung 2: readonly fields. The same guarantee for plain fields — the compiler permits assignment only at declaration or in the constructor:

public class Account
{
    private readonly string _id;
    public Account(string id) => _id = id;
}

Rung 3: init-only properties (C# 9+). Sometimes you want callers to use friendly object-initializer syntax, but still lock the value after creation. Replace set with init:

public class StudentRecord
{
    public string AdmissionNo { get; init; }
    public string DateOfBirth { get; init; }
}
 
var s = new StudentRecord
{
    AdmissionNo = "PNE-1042",      // allowed: we are still at the birth moment
    DateOfBirth = "2013-08-09",
};
 
s.DateOfBirth = "2014-03-30";      // CS8852: init-only, object already created

init is the precise legal language for "the pen exists only inside the municipal office, on the day of issue." Deserializers and object initializers can write the value during creation; the moment construction finishes, the ink dries.

Rung 4: records — whole-object immutability in one keyword.

public record TrainTicket(string Pnr, string PassengerName,
                          string TrainNo, string JourneyDate, string Seat);
 
var ticket = new TrainTicket("8642097531", "Meera", "12626", "2026-07-15", "32B");
 
// "Changing" the seat = issuing a fresh certificate, old one untouched:
var moved = ticket with { Seat = "41C" };
 
Console.WriteLine(ticket.Seat);   // 32B — original unharmed
Console.WriteLine(moved.Seat);    // 41C — new object

Positional record properties are init-only automatically, records compare by value, and the with expression answers the eternal question "but how do I change it then?" — you don't; you create a corrected copy. This non-destructive update style is exactly how real certificates work: corrections produce a freshly issued document, and anyone holding the old one can see it is the old one.

RungSyntaxWritable when?Best for
readonly fieldprivate readonly string _id;Declaration or constructorInternal state
Get-only propertypublic string Id { get; }Constructor onlyPublic ink facts
Init-only propertypublic string Id { get; init; }Constructor + object initializerDeserialization-friendly ink
Recordrecord Ticket(string Pnr, ...)Construction; copies via withWhole-object immutability

Python offers the same ladder in its own dialect — a frozen dataclass locks every field, and a read-only property guards a single one:

# Python: ink via frozen dataclass; guarded pencil via a normal class
from dataclasses import dataclass
 
@dataclass(frozen=True)
class TrainTicket:
    pnr: str
    passenger_name: str
    train_no: str
    journey_date: str
    seat: str
 
    def reallocate_seat(self, new_seat: str) -> "TrainTicket":
        # no mutation: return a corrected copy, old ticket untouched
        from dataclasses import replace
        return replace(self, seat=new_seat)
 
ticket = TrainTicket("8642097531", "Meera", "12626", "2026-07-15", "32B")
moved = ticket.reallocate_seat("41C")
# ticket.pnr = "000"   # FrozenInstanceError — there is no pen

dataclasses.replace is Python's with expression: a freshly issued ticket, the old one unharmed.

🧰 IDE support

The tooling does a lot of the watching for you:

  • Visual Studio: the built-in code-style rule IDE0044 ("Add readonly modifier") flags private fields that are only ever assigned in the constructor — the IDE itself tells you which fields are secretly ink. Quick Actions (Ctrl+.) apply the fix. The compiler errors CS0200 (read-only property) and CS8852 (init-only after creation) then guard the rule forever.
  • JetBrains Rider / ReSharper: inspections suggest making fields readonly and converting properties to get-only or init-only when no later writes exist; since the 2020.3 releases both tools understand records and init accessors fully, including quick-fixes to convert classes to records. Find Usages on a setter (Shift+F12 / Alt+F7) gives you the two piles from Step 2 in seconds.
  • IntelliJ IDEA (Java): the inspection "field may be final" plays the same role for Java, and the Refactor menu can introduce constructor parameters when you remove a setter.
  • VS Code (TypeScript): no single automated action, but the method is simple: add readonly to the field, and the TypeScript compiler's error list becomes your complete checklist of every illegal write. Find All References on the setter shows you which callers to migrate first.

As always, the IDE handles the mechanics. Deciding which fields are ink — that judgement about your domain is the human part.

⚖️ Benefits and risks

BenefitsRisks / Costs
"Never changes" becomes a compile-time guarantee, not a polite request — wrong code cannot even buildFrameworks needing parameterless constructors + setters may break; check constructor binding / init support first
Debugging shrinks: a value set once can never have been "changed by someone, somewhere"If the value legitimately changes during life, removing the setter is simply wrong — sort ink from pencil first
Safe sharing: many modules, threads, or caches can hold the object with zero fear of it changing underneath themRemoving a public setter from a published library is a breaking change for external callers
Construction becomes one honest, complete step instead of a fragile new-plus-setters ritualMulti-step initialization (builders, wizards) may need an init accessor or builder pattern instead of full removal
The missing setter documents intent better than any comment — readers instantly know the field is fixedUpdating immutable objects means creating copies; in extremely hot loops this can cost allocations (measure before worrying)

🩺 Which smells does it cure?

SmellHow Remove Setting Method helps
Mutable data everywhereShrinks the mutable surface: fewer fields can change, so fewer states the object can be in and fewer ways for it to go wrong
Large ClassEvery deleted setter trims the class's public surface; what remains is the real contract
Long Parameter List (in disguise)Replaces the new-then-six-setters construction ritual with one complete constructor — initialization stops being smeared across callers
Data ClumpsFreshly introduced parameter objects stay trustworthy: born valid, shared safely, immune to aliasing surprises
Data ClassCutting reflexive get/set pairs forces the question "what does this class actually do?" — behaviour starts replacing raw access

The whole locking-down toolkit, on one map — Remove Setting Method is one branch of a bigger family that also includes Hide Method and Encapsulate Collection:

Figure 9: Remove Setting Method in the family of locking-down refactorings

📝 Quick revision box

+=================================================================+
|           REMOVE SETTING METHOD — REVISION CARD                 |
+=================================================================+
| SMELL SIGN : setter on a field that should be fixed at birth    |
|              (id, orderNo, pnr, createdAt, dateOfBirth)         |
| PICTURE    : birth certificate — written ONCE, no pen attached  |
+-----------------------------------------------------------------+
| THE MOVE   : 1. Confirm the field is INK, not pencil            |
|              2. Find Usages -> two piles: at-birth vs later     |
|              3. Constructor receives the value                  |
|              4. Migrate at-birth setter calls into constructor  |
|              5. DELETE setter; make field readonly              |
|                 -> compiler lists every illegal write for you   |
|              6. Test, incl. serializers / ORMs                  |
+-----------------------------------------------------------------+
| C# LADDER  : readonly field -> { get; } -> { get; init; }       |
|              -> record (+ with-expressions for changed copies)  |
| REMEMBER   : "change" an immutable object = issue a NEW one;    |
|              pencil values get named methods, never raw setters |
+=================================================================+

🏋️ Practice exercise

A food delivery app in Kolkata has this class:

class Order {
  orderId = "";
  customerId = "";
  placedAt = "";
  restaurantId = "";
  status = "PLACED";
  deliveryBoyId = "";
  totalPaise = 0;
}
 
// Found around the codebase:
order.orderId = "ORD-" + Math.random();    // set after new Order()
order.placedAt = new Date().toISOString(); // also set after construction
order.status = "DELIVERED";                // set directly from 5 places
order.totalPaise = order.totalPaise - 500; // "discount" applied by mutation
order.customerId = "CUST-ADMIN";           // a support tool "fixing" data

Your tasks:

  1. Sort the seven fields into ink and pencil. For each ink field, say in one line why it must never change after the order is created. (Use the Figure 4 quadrant chart to place each field.)
  2. Apply Remove Setting Method step by step for the ink fields: route values through the constructor, migrate the creation-time assignments, then mark the fields readonly. Keep the code compiling at every step.
  3. status is pencil — but raw assignment from five places is dangerous. Replace it with methods accept(), pickUp(deliveryBoyId), and markDelivered(), each checking that the move is legal (e.g., an order cannot be delivered before pickup). Where does deliveryBoyId get written now? Draw the allowed moves as a state diagram like Figure 5.
  4. The "discount by mutation" line is a bug factory. Redesign totalPaise so the total is fixed at construction and a discount produces a new value through a method like withDiscount(paise) returning a new Order. What happens to code still holding the old object — and why is that a good thing?
  5. Bonus (C#): write Order as a record with the ink fields positional and Status handled through methods or non-destructive with updates. Show one with expression in action. Bonus (Python): write the same as a frozen dataclass using dataclasses.replace.
  6. College-corner question: two background threads share the same Order — one prints invoices, one sends SMS updates. Explain in three sentences why your refactored design needs no locks for the ink fields, and what would still need care for status.
  7. Reflection question: your analytics team says they need to "fix" old orders' placedAt for a timezone migration. Should you re-add the setter? What is the honest alternative — and what would Mr. Kannan's office do?

Frequently asked questions

How do I 'change' a value if the object is immutable and has no setters?
You do not edit the old object — you create a new one with the new value. A corrected birth certificate is a freshly issued certificate, not the old paper with scratching. In TypeScript, write a method like withSeat(newSeat) that returns a new object; in C#, records give you this for free with the with-expression: ticket with { Seat = "42A" }. The old object stays untouched, so anyone still holding it sees consistent data.
My ORM or JSON serializer needs setters to fill the object. Do I have to keep them public?
Usually not. Modern frameworks support better options: Entity Framework Core and System.Text.Json can both use constructor binding, and C# init accessors let deserializers set a property during object creation while still blocking later changes. Check your framework's documentation before surrendering — a private setter or an init accessor almost always satisfies the framework without opening the field to the whole world.
Should I remove every setter from every class?
No. Remove the setter only for fields that genuinely should not change after construction — identities, creation timestamps, defining values. A field that legitimately changes during the object's life, like a player's score or an order's status, deserves controlled mutation, ideally through an intention-revealing method like addPoints() rather than a raw setter. The skill is telling 'written in ink' fields apart from 'written in pencil' fields.
What is the difference between readonly, get-only, init, and a record in C#?
Four levels of the same idea. A readonly field can only be assigned in the constructor. A get-only auto-property ({ get; }) is the property version of that. An init accessor ({ get; init; }) additionally allows setting in an object initializer during creation, then locks. A record applies init-only to all positional properties at once and adds value equality and with-expressions — full-object immutability in one keyword.
Why exactly are immutable objects easier and safer?
Three plain reasons. First, no surprises: a value checked once stays checked — nobody changed it behind your back, so debugging hunts for 'who modified this?' simply disappear. Second, safe sharing: you can hand the same object to ten modules, ten threads, or a cache with zero fear, because nobody can spoil it for the others. Third, honest code: the absence of a setter tells every future reader 'this is fixed' more reliably than any comment could.

Further reading

Related Lessons