Skip to content

The rootId and rootClassId Columns

Overview and Motivation

Two columns are quietly added to entity tables by the Tentackle model when — and only when — they are actually needed:

Attribute Column Java type Holds
rootId rootid long the id of the aggregate root this component belongs to
rootClassId rootclassid int the class id of that aggregate root (its type)

Both belong exclusively to components — PDOs that live inside another PDO's aggregate. A root entity never carries them (it is the root), and an embedded entity never carries them (it has no table of its own). Their single purpose is to let any component name its aggregate root directly, by id and type, without walking back up through every intermediate aggregate level and without a database query.

That one capability turns out to underpin four otherwise unrelated parts of the framework — Domain-Driven Design boundaries, authorization, immutability, and query performance. This document explains what the columns are, when the model adds them, and how each of those four concerns leans on them.

The constants are defined in Constants (CN_ROOTID/AN_ROOTID, CN_ROOTCLASSID/AN_ROOTCLASSID) and surfaced on every PDO through PersistentObject (getRootId(), isRootIdProvided(), getRootClassId(), isRootClassIdProvided()).


When the Model Adds Them

rootid and rootclassid are two of the entity-level option flags described in Model Definition → Entity-level option flags, but you rarely write them by hand. They are normally part of the project-wide modelDefaults:

<tentackle.modelDefaults>remote, bind, size, autoselect, tracked, root, rootid, rootclassid</tentackle.modelDefaults>

Listing them as defaults does not add a column to every table. It enables the model to add them automatically, per entity, only where the topology demands it. The inference runs over the model as a whole (EntityImpl) and applies three rules:

  1. root — If an entity is not used as a component of any aggregate, it is a root entity. (A root entity need not actually have components — it can stand alone. It corresponds to the DDD aggregate root.)

  2. rootId — If an entity is a component of an aggregate but has no attribute that already points at its root entity, a rootId column is added and filled with the root entity's id. If a parent-id attribute that already reaches the root exists, that column is reused and no extra column is generated.

  3. rootClassId — If a component type is used in more than one aggregate (a multi-homed component — the same table is shared by different root classes), a rootClassId column is added so that a component row also records which kind of root it belongs to, not just which id.

The model exposes what the raw model asked for through the ...AccordingToModel predicates (isProvidingRootIdAccordingToModel(), isProvidingRootClassIdAccordingToModel(), isRootEntityAccordingToModel()), which the inference then resolves into the effective per-entity flags.

Two combinations are rejected at model-validation time (EntityImpl):

  • an embedded entity may not provide rootid/rootclassid (it has no table); and
  • a root entity may not provide them either (it is its own root).

You can override the inferred flags per entity in the global options line — e.g. [-root, untracked] forces an otherwise-standalone entity to be treated as a component, and the application may then steer the decision by overriding isRootEntity().

Setting and reading the values at runtime

For a new component, the values are stamped from the DomainContext when the id is assigned (AbstractPersistentObject.newId()):

if (!isRootEntity()) {
  if (isRootIdProvided())      { setRootId(context.getRootId()); }
  if (isRootClassIdProvided()) { setRootClassId(context.getRootClassId()); }
}

On load, the same context coordinates (getRootId() / getRootClassId()) flow back from the columns. Because every root entity maintains its own copy of the domain context — the root context — the component and its root always agree on these two numbers.


Relation to DDD and Aggregates

Tentackle treats DDD aggregates as first-class citizens: every PDO is either a root entity (the aggregate root) or a direct/indirect component reachable only through it. rootId/rootClassId are how the persistence layer makes that boundary physical.

  • The aggregate root is always one hop away. A grandchild component three levels deep does not need to traverse parent → child → grandchild to find its root; the rootId (and, if multi-homed, rootClassId) name the root outright. This is what lets a component "know" its root entity without a database query.

  • The boundary is enforced, not merely documented. When the whole aggregate is persisted as one consistent unit, the root context spans the root and every component; the root coordinates carried by each component are what the framework checks the parts against. A component whose rootId/rootClassId do not match the context it is being manipulated in is recognized as foreign to that aggregate.

  • Multi-homed components stay unambiguous. When the same component type can belong to several different root classes, rootClassId keeps the back-reference exact — an Attachment row knows it belongs to Invoice #42, not merely to id 42 of some unspecified type.


Relation to Security

Tentackle's authorization subsystem answers "may this grantee perform this permission on this object within this domain context?" on every tier, and it enforces read access at the aggregate granularity. rootId/rootClassId are what make that enforcement possible for components.

The principle is: a component is visible only if its aggregate root is visible. If you may not read the Invoice, you may not read its InvoiceLines either — and the framework behaves as if the rows did not exist rather than raising an error.

  • For a root entity, the root context is checked directly against the read permission (assertRootContextIsAccepted()).

  • For a component loaded outside its own root context (a deep link / deep reference that reaches lines without their owning root), the framework reads the component's rootClassId + rootId and asks the SecurityManager to evaluate the read permission of that root entity. If the verdict is denied, the component is dropped from the result (return null) — never handed to the caller:

SecurityResult sr = SecurityFactory.getInstance().getSecurityManager()
    .evaluate(context, SecurityFactory.getInstance().getReadPermission(),
              poRootClassId, poRootId);
if (!sr.isAccepted()) {
  return null;   // root not readable -> component does not exist for this user
}

Because the check needs only two integers carried on the component row, it costs no extra query, and a small lastCheckedRoot cache skips re-evaluating the same root across a result list. Without rootClassId/rootId, evaluating a rule defined on the root while selecting components would require re-loading the root for every row.


Relation to Immutability

The same root coordinates let the framework decide, at load time, whether an object may be modified — protecting the DDD invariant that an aggregate is responsible for its own consistency, and nothing may write to it behind its back.

When a component is loaded through a path that is not its own root context — for example, via a deep reference that pulls lines without their owning root, or from within a different aggregate's context — the framework marks it finally immutable (setFinallyImmutable()):

if (poRootId != 0 && poRootId != context.getRootId() ||
    poRootClassId != 0 && poRootClassId != context.getRootClassId()) {
  po.setFinallyImmutable();   // wrong root context -> read-only
  ...
}

This prevents a subtle and otherwise silent class of bug: editing a component that you happened to reach through the wrong aggregate and persisting it where it does not belong. The object is still perfectly usable for reading; it simply refuses to be changed or saved outside the aggregate that owns it. The dual root/component case (an entity that is programmatically sometimes a root and sometimes a component) uses the very same rootId/rootClassId comparison to become read-only when loaded as a borrowed component. See the immutability mechanics in immutable.md.


Relation to Performance

This is where the columns earn their keep on the hot path. They exist primarily to collapse aggregate-traversal joins into a single equality, and to compare types as integers instead of strings.

  • One comparison instead of a join chain. A condition on a deeply nested component would naïvely require joining every intermediate aggregate level (root → child → grandchild → …) just to correlate the component row with its root. When a path from a root begins with a chain of composite component relations, the generator (pdo-select → How the path to the root is reduced) compacts that whole leading chain into one predicate in the correlated EXISTS subquery:
root.<id> = component.<rootId>        -- plus  root.<classId> = component.<rootClassId>  for multi-homed components

Only relations after the compacted component prefix (e.g., a link out to a foreign PDO) still need their full join conditions. ComponentInfo resolves which column actually supplies the root id: the component's own CN_ROOTID when it has the rootid option, otherwise an existing parent-id attribute that already reaches the root (so no redundant column is added).

  • Loading a whole aggregate is one indexed lookup. Selecting all components of a given root is a single WHERE rootid = ? (AND rootclassid = ? when multi-homed) against an indexed column — no multi-level join, no per-row follow-up.

  • Class ids are ints, not class names. rootClassId stores the root's class id rather than its fully-qualified class name. Comparing integers is faster than comparing strings in both Java and the database, and — just as importantly — it survives refactoring: renaming or repackaging a class does not change its class id, so stored rows stay valid.

  • No query to find the root at runtime. Because the value travels on the row, the security and immutability checks above, and any application code that needs the root's identity, read it from memory instead of issuing a lookup.

The trade-off is one (or two) extra small columns on component tables and the discipline of stamping them on insert — paid once, on writes, to save joins and queries on every read.


Summary

Concern What rootId / rootClassId provide
DDD A component names its aggregate root directly; the aggregate boundary becomes physical and enforceable.
Security Read access is checked against the root; an unreadable root hides all its components, at no extra query.
Immutability A component loaded outside its own root context is made read-only, preventing cross-aggregate writes.
Performance Aggregate-traversal join chains collapse to one indexed equality; type comparison is an int, refactor-safe.

The columns appear only where the model topology requires them — components without an existing path to their root get rootId; components shared across root classes also get rootClassId — and are absent from root and embedded entities entirely.

See also