Constructors and invariants
Use the constructor to make a broken object impossible rather than unlikely, and know why it must never call a method a subclass can override.
An invariant is a statement about an object that is true the moment it exists
and true again after every method returns. For a date range it is start <= end.
Once that holds, days() cannot return a negative number, overlaps() needs no
defensive check, and a monthly report cannot quietly sum a −3. The constructor
is where the statement is made true, and it is the only place where it can be.
Refuse to build the broken one
The naive version validates somewhere else — a validate() the caller is
supposed to remember, or a check in the service layer above:
r = DateRange()
r.start = start
r.end = end
r.validate() # if anyone calls it
Between line 1 and line 4 there is an object that is legal to pass anywhere and wrong. And the check now lives at the call site, so forty call sites give forty chances to skip it. Move it into the one place every object must pass through:
class DateRange:
def __init__(self, start, end):
if start > end:
raise ValueError(f"start {start} is after end {end}")
self.start = start
self.end = end
def days(self):
return (self.end - self.start).days + 1
public final class DateRange {
private final LocalDate start;
private final LocalDate end;
public DateRange(LocalDate start, LocalDate end) {
if (start.isAfter(end)) {
throw new IllegalArgumentException("start " + start + " is after end " + end);
}
this.start = start;
this.end = end;
}
public long days() { return ChronoUnit.DAYS.between(start, end) + 1; }
}
The check is one comparison of two fields already in cache — an L1 reference is about 1 ns, so call the whole guard well under 100 ns. The cheapest way for an invalid range to be caught later is a query that returns nothing, and a round trip inside a datacentre is 0.5 ms: 0.5 ms ÷ 100 ns = 5,000 times the cost, for a worse error message. The expensive failure is the one that is never caught at all and ships in a report.
The gate only holds if nothing walks around it. A set_end() that assigns
without checking is a second entrance, and one entrance without a guard is the
same as no guard. Either it validates too, or it should not exist — which is why
types with real invariants tend to become immutable, returning a new object
instead of editing this one.
Several ways to build one thing
Java overloads constructors. The discipline that keeps overloading honest is to have every overload delegate to one canonical constructor, so the validation is written once:
public DateRange(LocalDate start, int days) {
this(start, start.plusDays(days - 1)); // delegates; the check runs there
}
Python has no overloading — one __init__ per class — and the replacement is a
named constructor as a classmethod:
@classmethod
def of_length(cls, start, days):
if days < 1:
raise ValueError("days must be at least 1")
return cls(start, start + timedelta(days=days - 1))
Named constructors read better than one constructor with five optional
parameters. DateRange.of_length(d, 7) says what it builds; DateRange(d, None, 7, None) makes the reader count commas. They also do something a constructor
cannot: a constructor either returns an object or raises, while a factory can
return None or an Optional when failing is an ordinary outcome rather than a
bug. Parsing user input is the usual case — DateRange.parse("2026-13-01")
returning None is a normal Tuesday, and an exception per bad form submission
is a stack trace for something that is not exceptional.
The half-constructed object
A constructor that calls an overridable method hands out a reference to an object that is not finished yet:
class Range {
private final int size;
Range(int size) { this.size = size; describe(); } // overridable
String describe() { return "range of " + size; }
}
class Named extends Range {
private final String label;
Named(int size, String label) { super(size); this.label = label; }
@Override String describe() { return label.toUpperCase(); } // label is null
}
super(size) runs before the subclass constructor body, so describe()
dispatches to the override, which reads label before anything has assigned it.
Java has already zeroed the fields, so label is null and this is a
NullPointerException — or, for an int field, a silent 0 that produces a wrong
answer with no exception at all.
Python does the same dispatch and fails a little more loudly: the attribute has
not been created yet, so it is an AttributeError rather than a plausible
default. C++ changes the answer outright. During a base constructor the object's
dynamic type is still the base, so a virtual call resolves to the base version —
no crash, no override, and nothing to notice.
Three languages, three behaviours, one rule: a constructor calls only methods
that cannot be overridden. In Java that is private or final. In Python a
leading underscore buys nothing here — it is a convention, and a subclass that
defines _describe replaces the base version exactly as an override would, so
the half-constructed object comes back unchanged. The mechanism that actually
prevents it is double-underscore name mangling: written as self.__describe()
inside Range, the compiler rewrites the call to self._Range__describe(), and
a subclass that defines __describe gets _Named__describe, a different
attribute the base constructor cannot reach. If the work genuinely needs the
subclass, construct first and call afterwards, from a factory method that
returns the finished object.
In an interview
The question behind the question is whether you can say what a class guarantees.
Practise naming the invariant, where it is established, and what could break it:
"The invariant is start <= end. The constructor throws otherwise, the fields
are final, and shifted() returns a new range, so there is no method that can
break it."
The mistake that loses points: answering "I would add validation" without saying where. Validation in a service above the object leaves the object buildable from anywhere else, and an interviewer who hears it will ask you to write the second call site. The related slip is adding a setter for symmetry; say instead which fields have no setter, and why that is the point rather than an omission.
If the design has several construction paths, name them and say which one holds the check. That is the same argument as the one door in state and behaviour, applied to birth rather than to writes.
Check yourself
A DateRange validates start <= end in its constructor and also exposes
setEnd() that assigns without checking. Is start <= end still an invariant?
No. An invariant has to hold after every method returns, and one unguarded writer is enough to break it. Either
setEnd()validates, or it returns a new range, or it does not exist.
A parser turns user-typed text into a Money. Constructor or factory, and
what does the choice buy?
Factory. A constructor can only return an object or throw, so invalid input becomes an exception on a path where invalid input is expected. A factory returns
NoneorOptional.empty(), which the caller has to handle. Keep the constructor private so the factory stays the only way in — and see identity and equality for why aMoneybuilt this way should also be immutable.
A Report base class needs setup that only the subclass can supply — each
subclass knows its own column list — and it needs it before the object is handed
out. Give the construction sequence you would use instead of calling the
overridable method from the constructor, and say what the caller can no longer
do.
Split birth into two steps and hide the first. The constructor assigns and checks only the fields the base class owns, and never calls out. A static factory —
Report.create(...)— constructs the finished object, then calls the subclass hook on it and returns it. What the caller gives up isnew Report(…)itself: the constructor has to stop being public, because any direct call now yields an object whose columns have not been set. That is the real cost, and worth naming out loud — the type no longer guarantees a usable object at the end of construction, only at the end of the factory, so "it compiled" stops being proof and the factory becomes the invariant's new gate.