Lesson 11 / 19
Object and Table Binding
Two of an entity object's four fields load at load time, two load on first access. A second access does not read the table again, it returns from the cache; but once the object has detached from the container, accessing a field that was never accessed before cannot reach the table, and the defect silently comes back as an empty value.
Published
In this lesson
- Measures lazy loading by counting that two of an entity object's four fields load at load time and two load on first access
- Shows that a second access to the same lazy field does not read the table again, it returns from the cache
- Measures that the table cannot be reached, and the defect stays silent, when a lazy field that was never accessed before is accessed after the object has detached from the container
Contents
The previous lesson measured which implementation stands behind an interface, but it never asked how the implementation’s own fields match between an object and a repository. This lesson takes this up: it measures that the binding between an object’s fields and a table’s columns comes from an annotation in the source, and that a field that is never accessed is never loaded at all. In the course’s terminology decision, this connection is object-table binding; the object itself is an entity object.
The table is modeled here too: no real database connection is opened, no real query is run.
A “table” is nothing but a column-name-to-value structure accessed by row identifier —
Map<Integer, Map<String, Object>>. An entity object is bound to one of these rows, and
which of the object’s fields corresponds to which column is specified in the source with two
annotations: @Column for fields that always load, @LazyColumn for fields that load only
when accessed.
It should not be forgotten that this distinction is a question of cost. In a real
repository, every column can carry a disk access, a network delay; always reading only two
columns of a row and reading the rest only when needed means never paying anything for
fields of an object that will never be used. This lesson does not measure the cost itself —
no duration or delay is written — but it measures where the cost falls: the loadOrder
list is a log that records which column was read and when, and this log records every read,
both at load time and at access time, in the same form.
Two Fields at Load Time, Two Fields on Access
- WD20.
ProductCardcarries four fields:nameandquantityare@Column,descriptionCacheandimagePathCacheare@LazyColumn. Field names do not have to match column names exactly; the annotation’snameparameter makes the match independent of the field’s own name in the source. - WD21. A lazy field’s real value is kept in a “cache” field; this field,
nullthe first time, is never reset again after the first successful load.
When Container.load turns a row into an object, it fills only the fields carrying
@Column; it never touches the ones carrying @LazyColumn. The object carries which
container and which row it belongs to through the session and rowId fields inherited
from the EntityObject superclass — these two are not @Column, they are a link the object
container sets up itself.
// Container.java — object-table binding arises from an annotation, a lazy column loads at access time
import java.lang.annotation.*;
import java.lang.reflect.*;
import java.util.*;
public class Container {
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE)
@interface Table { String name(); }
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD)
@interface Column { String name(); }
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD)
@interface LazyColumn { String name(); }
final Map<Integer, Map<String, Object>> table = new LinkedHashMap<>();
final List<String> loadOrder = new ArrayList<>();
void addRow(int id, Map<String, Object> columns) { table.put(id, columns); }
Object readColumn(int id, String columnName, String label) {
loadOrder.add(columnName + ": " + label);
return table.get(id).get(columnName);
}
<T> T load(Class<T> type, int id) throws ReflectiveOperationException {
T object = type.getDeclaredConstructor().newInstance();
for (Field f : type.getDeclaredFields()) {
f.setAccessible(true);
Column c = f.getAnnotation(Column.class);
if (c != null) f.set(object, readColumn(id, c.name(), "at-load-time"));
}
Field sessionField = type.getSuperclass().getDeclaredField("session");
Field idField = type.getSuperclass().getDeclaredField("rowId");
sessionField.setAccessible(true);
idField.setAccessible(true);
sessionField.set(object, this);
idField.set(object, id);
return object;
}
void detach(EntityObject object) throws ReflectiveOperationException {
Field sessionField = EntityObject.class.getDeclaredField("session");
sessionField.setAccessible(true);
sessionField.set(object, null);
}
public static void main(String[] args) throws Exception {
Container container = new Container();
container.addRow(1, Map.of("name", "screw", "quantity", 12, "description", "6mm screw with a nut", "imagePath", "image/screw.png"));
System.out.println("-- loading through the container: only two columns are read at load time --");
ProductCard product = container.load(ProductCard.class, 1);
System.out.println("load order after loading: " + container.loadOrder);
System.out.println("total fields: 4, read at load time: "
+ container.loadOrder.stream().filter(s -> s.endsWith("at-load-time")).count());
System.out.println("-- accessing a lazy field: the first call reads the table --");
System.out.println("description: " + product.description());
System.out.println("description again: " + product.description());
System.out.println("load order (after description): " + container.loadOrder);
System.out.println("how many times the table was read for description: "
+ container.loadOrder.stream().filter(s -> s.startsWith("description:") && s.endsWith("at-access-time")).count());
System.out.println("imagePath was never accessed, never appears in the record: "
+ container.loadOrder.stream().noneMatch(s -> s.startsWith("imagePath")));
System.out.println("-- lazy field never accessed before detaching from the container --");
container.detach(product);
System.out.println("imagePath (first access after detaching from the container): '" + product.imagePath() + "'");
System.out.println("defect type: silent (no exception, an empty value silently came back)");
System.out.println("-- manually constructed object: the container is never involved --");
ProductCard manual = new ProductCard();
System.out.println("in a manually constructed object, description on first access: '" + manual.description() + "'");
}
}
class EntityObject {
transient Container session;
transient int rowId;
Object lazyRead(String columnName, String cache) {
if (cache != null) return cache;
if (session == null) return "";
return String.valueOf(session.readColumn(rowId, columnName, "at-access-time"));
}
}
@Container.Table(name = "product_card")
class ProductCard extends EntityObject {
@Container.Column(name = "name") String name;
@Container.Column(name = "quantity") int quantity;
@Container.LazyColumn(name = "description") String descriptionCache;
@Container.LazyColumn(name = "imagePath") String imagePathCache;
String description() {
descriptionCache = (String) lazyRead("description", descriptionCache);
return descriptionCache;
}
String imagePath() {
imagePathCache = (String) lazyRead("imagePath", imagePathCache);
return imagePathCache;
}
}
-- loading through the container: only two columns are read at load time -- load order after loading: [name: at-load-time, quantity: at-load-time] total fields: 4, read at load time: 2 -- accessing a lazy field: the first call reads the table -- description: 6mm screw with a nut description again: 6mm screw with a nut load order (after description): [name: at-load-time, quantity: at-load-time, description: at-access-time] how many times the table was read for description: 1 imagePath was never accessed, never appears in the record: true -- lazy field never accessed before detaching from the container -- imagePath (first access after detaching from the container): '' defect type: silent (no exception, an empty value silently came back) -- manually constructed object: the container is never involved -- in a manually constructed object, description on first access: ''
The initial state of container.loadOrder carries only two entries: name and quantity.
description and imagePath do not appear in this list at all, because load never read
them — a four-field object was built by reading only two of the table’s columns. This is not
a result that contradicts the source text, it is exactly what the source says: @Column
exists on only two fields, @LazyColumn on the other two, and load fills only the ones
carrying @Column. But someone reading the row knows all four of ProductCard’s fields
come from a table row; knowing which two come when requires reading the body of
Container.load.
A Lazy Field Loads Only Once
- WD22. When the
description()method is called, it first checks its own cache field; if that field is filled, it never goes to the table at all. This is the second meaning of the method being “lazy”: it loads not merely on first access, but at most once.
description() is called twice, and only one "description: at-access-time" entry is
added to the loadOrder list. The first call sees that the descriptionCache field is
null, reads the table, and writes the result into the same field. The second call sees
that the same field is now filled and never goes to the table at all. This is a behavior
from the same family as the singleton scope inside the container — there, a component’s
second request returned the same instance; here, a field’s second access returns the same
value — but its source is different: singleton scope lived in the container’s own rule
table, here the caching is a check written into the lazyRead method’s own body. Since the
imagePath field is never accessed, there is no entry for this column in the loadOrder
list at all — the fourth field of a four-field object can go the whole length of the program
without ever being asked of the table. This is lazy loading’s real payoff: an object
carrying all of its fields does not mean all of them are actually read, and no cost is paid
for a field that is never read.
Caching carries not only a performance decision but a consistency decision too. If some
other path changed the description column of the same row between two calls to
description(), the product object would never see it — once the cache is filled,
lazyRead never looks at the table again. This lesson does not run this scenario, but the
result can be inferred from the source: a lazy field carries only the value it read at the
moment it was first read, and the object does not change even if the table changes after
that moment. For a caller that holds an entity object for a long time, this means the load
moment is a genuine snapshot, and nowhere is it promised that this snapshot will remain
correct indefinitely.
Lazy Loading Does Not Work Outside the Container: A Silent Gap
- WD23.
detachonly sets thesessionfield tonull; the object’s other fields, including the already-loadednameandquantity, do not change at all. - WD24.
lazyRead, when it seessession == null, returns an empty string instead of throwing an exception. This is the lesson’s second silent defect: the access does not fail, it merely produces a wrong value.
The call container.detach(product) cuts product‘s link with the container’s session — in
a real application, this corresponds to the object being carried outside the container, to
some other layer, once a request has finished processing. Up to this point, imagePath had
never been accessed; the first time it is called after detach, lazyRead now sees that
session is null, and there is no way left to ask the table. The method returns an empty
string instead of throwing an exception, and the caller cannot tell this empty string apart
from a genuine “there is no image path” state. The defect here is silent too: the program
does not crash, it prints no warning, it just continues with a wrong value.
This is the lesson’s bounding measurement: lazy loading does not work outside the
container; a field accessed after the object has left the container stays empty. name
and quantity do not have this problem, because both were filled at load time and are
unaffected by detach — the problem surfaces only in lazy fields that were never accessed.
This also reveals a hidden rule of the loading strategy: how long an entity object stays
inside the container decides which of its lazy fields get accessed, and once that access
window closes, any lazy field left unaccessed can never reach its correct value again.
The last measurement tests this rule with an object that never had it set up in the first
place: an object manually constructed with new ProductCard() has a session field that
was never bound to a container, null from the start. Calling description() on this
object also gives an empty string — behavior identical to the detached object, the same
silent result. Both paths land on the same point: the object either was never bound to the
container’s session or its bond broke later, and in either case accessing the lazy field
ends in the same empty value. The @LazyColumn annotation keeps standing on the
ProductCard class — it was not removed — but without a session to fulfill it, this once
again confirms the rule seen in the previous lessons: an annotation is a request, and if
there is no party to fulfill the request, nothing happens.
The only difference between the two is how surprising the defect is. In a manually
constructed object, it is already clear from the start that the container is never
involved — a developer constructing this object with new has to expect that lazy loading
will not work. But the object that went through detach was inside the container a moment
ago; its name and quantity fields carry correct values, and the object looks “real” in
every respect. This appearance gives the impression that a field never accessed, like
imagePath, is just as trustworthy — when in fact that field is still dependent on the
container’s session, despite the object’s apparent completeness. The bounding measurement’s
real value is here: the problem does not surface in some random field, it surfaces exactly
at the intersection of leaving the container most recently and never having been
touched, and this intersection is marked nowhere in the source text.
Summary
- Two of
ProductCard’s four fields (name,quantity) were read from the table at load time; two (descriptionCache,imagePathCache) were read only on first access. - A second access to a lazy field never went to the table at all; when the cache field was
filled,
lazyReadreturned it directly. - A lazy field that was never accessed (
imagePath) was never asked of the table for the rest of the program; this is the side of lazy loading that charges no cost for a field that is never read. - When a lazy field that was never accessed was accessed after the object detached from the container, the table could not be reached and the method returned an empty string instead of an exception; the defect stayed silent.
- Accessing the same lazy field on a manually constructed object gave the exact same empty result as the detached object; what both were missing was the container’s session.
Next Step
This lesson measured when an object’s fields load, but it never asked where this loading, and the changes made on top of it, begin and end. The next lesson takes this up: it measures that a declarative transaction boundary is an annotation in the source, and how a component calling itself skips this boundary.
To keep your progress and take notes, Log in
My notes
Log in to take notes.