skip to content

When is mapping a class hierarchy the wrong call, and what alternatives keep polymorphism out of the persistence layer?

level: principalimportance: should knowfreq 40%

answer

  1. mapping a base class is persistence-wide
  2. who needs the database to resolve types
  3. references beat list screens as evidence
  4. unmapped base class keeps code reuse
  5. splitting later is a migration under load

basics

~20 s

Map a hierarchy only when the database must resolve the type: base-typed references, authoritative cross-type queries, shared keys or hierarchy-wide rules. Otherwise keep the base class unmapped, or serve cross-type screens from a read-only projection.

solid answer

~50 s

Mapping a base class is a persistence-wide commitment, not a local declaration: base-type queries become polymorphic and grow with each subtype, keys share one space across the hierarchy, base-typed links lose cheap deferral or come back as stand-ins that fail type tests, and the class model becomes stored data that has to be migrated. That is worth paying when the database genuinely has to resolve types: a reference points at the base type without naming a kind, cross-type queries must be transactional, or invariants span the hierarchy. When the hierarchy is really code reuse, cheaper options exist: leave the base class unmapped and map each concrete class to its own table; fold the varying part into a type-tagged value; or serve the one cross-type list from a read-only projection over the concrete tables. Reversibility is asymmetric — adopting a hierarchy later is closer to additive than splitting one under load.

go deeper

for a junior

Understand that inheritance in code does not have to become inheritance in the database, and that mapping a base class has costs beyond the classes involved.

for a middle

Name the concrete commitments: polymorphic base-type queries, a shared key space, per-subtype growth in existing statements, and the type becoming stored data.

for a senior

Argue the decision from evidence in the model — count base-typed references and transactional cross-type queries — and propose a read-only projection where only a list screen is at stake.

for a principal

Own the asymmetry: weigh expected subtype growth, other readers of the table, and the fact that undoing a mapped hierarchy is a migration under load while adopting one later is nearly additive.

## What mapping a hierarchy actually commits you to Marking a base class as mapped is not a local decision about one class. It changes properties of the whole persistence layer: - **Every query against the base type becomes polymorphic.** The layer must be able to return any subtype from it, which fixes the statement shape and makes it grow as subtypes are added. - **Every subtype shares one identity space.** Keys have to be unique across the hierarchy, because a base-typed reference or a base-type read must resolve a key without knowing the class. - **Adding a subtype is a change to existing code paths.** A wider shared table, another join, or another union arm — the new class is never purely additive. - **Base-typed references may lose deferral** or come back as stand-ins that fail type tests, because a stand-in cannot be built without knowing the concrete class. - **The type becomes stored data.** Whether it is a marker value or the existence of a child row, the class model is now recorded in the database, and it must be migrated when the model changes. None of that is a reason to avoid mapped hierarchies. It is the price, and it is worth paying when the hierarchy is real in the data. ## When the commitment pays Ask what the model genuinely needs from the database, not from the code: 1. **Base-typed references.** Does something point at "a payment", not knowing which kind? A mapped hierarchy is the honest way to express that; without it, resolving the reference becomes application work. 2. **Cross-type queries that must be authoritative and up to date.** Reporting on all subtypes together, with filters and paging over the mixed set. 3. **Shared invariants enforced at the persistence layer** — uniqueness across all subtypes, one key space, cascade behaviour that must apply to every kind. 4. **Rules that apply to the hierarchy as a whole** — a global predicate, a version column, audit stamps you want applied identically. If two or more of those are true, map the hierarchy. If none is, the hierarchy is a code-reuse relationship and belongs in the code. ## Alternatives that keep polymorphism out of the persistence layer | alternative | what you keep | what you give up | |---|---|---| | Unmapped base class, each concrete class mapped to its own table | shared code and shared field declarations | base-type queries and base-typed references | | Common interface, no mapped root | polymorphism in the domain and in services | anything the layer must resolve by type | | One class with a type-tagged value object for the varying part | one table, one query shape, no hierarchy | compile-time separation of the variants | | Separate types plus a read-only projection over their union | one cross-type list screen, cheaply | writes and navigation through the projection | The last row is the one teams miss. A cross-type list is often the only reason a hierarchy gets mapped, and a read-only projection over the concrete tables serves that screen without imposing polymorphic reads, shared keys and stand-in typing on the rest of the system. ## How to decide, and how reversible it is 1. Count the base-typed **references** in the model. One real reference is a stronger argument for mapping than several list screens. 2. Count the queries that must mix types **transactionally**. Those a projection cannot serve. 3. Project the subtype count over the next couple of years. A hierarchy that grows a subtype a quarter pays the growth cost repeatedly; one with three stable subtypes barely notices. 4. Ask who else reads the table. A marker set is a contract with every reader, including services outside your build. Reversibility is asymmetric and that should weigh on the decision. Splitting a mapped hierarchy later means moving rows, re-keying, and rewriting every base-type query — a migration under load. Adopting a hierarchy later, over types that were already separate, is closer to additive: introduce the base table or the marker, backfill, then re-point references. When the case is genuinely balanced, the cheaper mistake is to start without the mapped hierarchy — you can add the commitment when the model proves it needs it, and you will have paid nothing for it in the meantime.

  • Why is a single base-typed reference stronger evidence than several cross-type list screens?
    A list can be served by a read-only projection over the concrete tables, because it needs no writes and no navigation. A reference must be resolved to a real object of the right class on demand, transactionally, from the key alone — that is precisely the work a mapped hierarchy exists to do.
  • What does an unmapped base class give you, and what does it deny you?
    It keeps shared fields and shared behaviour in one place while each concrete class maps to its own table, so nothing about the hierarchy leaks into the schema. What it denies is anything the layer must resolve by type: base-type queries and base-typed references both stop being expressible.
  • Why does the expected number of subtypes belong in the decision?
    Because the growth cost is paid per subtype in most layouts — a wider shared table, another join, or another union arm in every existing base-type query. Three stable subtypes barely register; a subtype every quarter turns each addition into a change with system-wide reach.
  • If the case is genuinely balanced, which way should you lean?
    Toward not mapping the hierarchy. Introducing a base table or a marker later, over types that were separate, is close to additive: create it, backfill, re-point references. Splitting a mapped hierarchy later means moving rows, re-keying and rewriting every base-type query on a live system.

saying these in an interview costs you the question

  • Maps a hierarchy purely because the classes share a base in code
  • Treats adding a subtype as a purely additive change
  • Ignores that base-typed links compromise deferral or type fidelity
  • Assumes a mapped hierarchy can be split later without a migration
  • Uses one cross-type list screen as sufficient reason to map