Home Design Patterns Lazy Load
Lazy Load
Modern PracticeDefer the cost of getting data until something actually asks for it.
Purpose
A Lazy Load is an object that does not contain all the data you will need, but knows how to get the rest. The reason to want one is that loading an object graph is not a single decision: a run has samples, the samples have annotations, the annotations have authors, and following every reference from one root would drag half the database into memory to answer a question about a job number.
Martin Fowler names four flavors in Patterns of Enterprise Application
Architecture, and they differ in where the check lives. Lazy
initialization puts it in the getter: test a field, load if it is unset,
return it. A virtual proxy puts it in a stand-in object with the same
interface as the real one, so the caller holds something that looks like a list
and is not one yet. A value holder is honest about itself: the field's
type is Holder, and the owner knows it is holding one. A
ghost is the real object, partially populated, that fills in the rest of
itself the first time an unloaded field is touched.
Design
All four share one flag and one loader. The flag is the part people get wrong.
Testing value == null is not the same as testing whether the load has
happened, because null is a legitimate result: a run with no samples reloads on
every single access, forever, and the symptom is a query counter that will not
stop climbing. You need a separate boolean.
getSamples() reads like a field access and can reach the datastore,
which is the pattern's entire benefit and its entire problem.The choice between flavors is really a choice about honesty. A value holder is visible in the field's type, so anyone reading the class knows where the I/O is. A virtual proxy or a ghost is invisible by design, which is what makes them comfortable to use and what makes them dangerous to reason about.
Example
Autobot Robotics's reporting side lists production runs. A run has a small header (job number, arm serial, operator, start, end, result) and a sample series: force and joint position captured at a kilohertz for as long as the run took. A long run is tens of megabytes of samples.
The run list page loaded two hundred runs to show a table of headers, and the mapper faithfully loaded every sample series with them. The report server ran out of memory on the second week a customer had it, and the fix was not subtle: wrap the samples in a holder. The list page touches nothing but the header, so the query never runs, and the page went from unusable to instant.
The pattern then produced its own bug, on schedule. The end-of-shift export loops over the same two hundred runs and does touch samples on each one, and because the loop looks like ordinary in-memory code nobody saw that it was issuing two hundred and one queries where one would have done. It took a slow night shift and a query log to find it. The fix was an explicit eager path for that one report, which is the honest answer: laziness is a default, not a policy, and the caller that knows better has to be able to say so.
class Run
{
private long id;
private Holder<Sample[]> samples;
public Run(SampleMapper m, long runId)
{
id = runId;
samples = new Holder<Sample[]>(() => m.load(runId));
}
// Reads like a field. Can open a socket, take a lock and
// block. Nothing in the signature says so.
public Sample[] getSamples() { return samples.get(); }
}
class Holder<T>
{
private bool loaded = false; // NOT a null check on value
private T value;
private Func<T> loader;
public Holder(Func<T> f) { loader = f; }
public T get()
{
if (!loaded)
{
value = loader();
loaded = true;
}
return value;
}
}
class SampleMapper
{
public Sample[] load(long runId)
{
// one query, run at most once per Run
}
}
if (!loaded) and
loaded = true. Using value == null as the test instead
is the classic bug, because an empty result then reloads on every access for the
life of the object.Seen in the Wild
Hibernate loads collections lazily by default and throws
LazyInitializationException when you touch one after the session has
closed, which is probably the most frequently asked question in the framework's
history. Entity Framework Core does the same thing through generated proxies, and
requires navigation properties to be virtual so it can override them.
Django's QuerySets are lazy and do not hit the database until they are iterated,
and Django ships select_related and prefetch_related
precisely because laziness produces N+1 queries. .NET promoted the pattern into
the standard library as System.Lazy<T>, with explicit
thread-safety modes because getting that part right by hand is hard. The oldest
example is not in application code at all: demand paging is Lazy Load in a memory
management unit, and copy-on-write after fork() is the same idea
again.
Comparisons
- Proxy is not merely related, it is one of the four flavors. A virtual proxy is a Proxy whose reason for existing is to delay construction of its subject.
- Singleton is where most programmers meet lazy
initialization for the first time, in the form of the check-and-create inside
getInstance(), along with all the double-checked locking arguments that come with it. - Repository is usually where the loader lives. The repository knows how to fetch, the holder knows when.
- Aggregate is the better tool for the same problem. If you can decide what belongs together and load an aggregate whole, you need far less laziness inside it.
- Flyweight also puts a lookup in front of object creation, but to share instances rather than to postpone work.
Why It Exists
My take
Lazy Load exists because the shape of an object graph in memory and the shape
of a query you can afford are different things, and object-relational mapping
pretends they are the same. Once run.samples is a plain reference,
the model has claimed that samples are part of a run, and something has to decide
when that claim gets paid for. Without laziness you either load too much, which
is the memory problem, or you make every caller assemble what it needs, which
means the domain model knows about the database and you have given up the thing
you were buying.
My honest view is that it is a performance technique wearing a modeling
technique's clothes. What it really does is make property access do I/O. Once
getSamples() can open a connection, wait on a lock, block for two
hundred milliseconds or throw, every caller's latency budget and error handling
is wrong, and nothing in the signature warns them. In control software that is
disqualifying, and not as a matter of taste: a field read whose worst case is
unbounded cannot appear inside a loop with a deadline. The reason it is tolerated
on the office side is that the office side has no deadlines, only averages.
It survives because the alternative asks callers to know their access pattern
in advance, and callers genuinely do not. But notice that every mature framework
eventually grows a way to override it: select_related,
Include(), fetch joins, GraphQL selection sets. That is the real
lesson. The interesting question was never "lazy or eager", it was "who gets to
decide", and the answer is the caller, per call site, because only the caller
knows whether it is showing one row or two hundred.
Criticisms
The N+1 query problem is the direct consequence of this pattern and it is probably the single most common performance bug in database-backed applications. Loop over N parents, touch a lazy child on each, and you have issued N+1 queries. What makes it so persistent is that it is invisible in the source: the loop is plain code operating on plain objects, the laziness is in a field type or in a generated proxy, and the only way to see the problem is to look at a query log under realistic data. It never reproduces on a developer's machine with three rows in the table.
The second one is laziness meeting a closed session, which is the same bug in Hibernate, Entity Framework, SQLAlchemy and every ORM that has ever shipped. You load an object inside a transaction, close the session, hand the object to a view or a serializer, and it touches an unloaded field. Now you get an exception at the outer edge of the system, far away from the code that decided what to load, and every standard remedy trades it for something else: open-session-in-view keeps a database connection alive for the whole render, eager-loading everything undoes the pattern, and mapping to DTOs means writing and maintaining a second set of types. There is no clean answer, which is why the question keeps getting asked.
Beyond those, the invisibility bites in places you do not expect. A serializer
that walks properties will trigger every lazy load in the graph and can pull the
whole database through one JSON response. So will a debugger's watch window, so
hovering over a variable changes the program's behavior. A logger calling
ToString() does it too. And the naive check-then-load has a race
condition: two threads both see the flag unset, both load, and if the loader has a
side effect such as opening a connection you now have two. Add that a proxy is not
the object it stands for, so getClass(), instanceof and
reference equality all give answers that surprise people, and the total cost of
"it just works" is considerably higher than it looks on the day you adopt it.