CodeGym /Kursy /Hibernate deep-dive /When hierarchy is needed in a model

When hierarchy is needed in a model

Hibernate deep-dive
Poziom 16, Lekcja 0
Dostępny

1. Introduction

When a developer first sees JPA annotations for inheritance, the hand reaches for them just as quickly as for @Data in Lombok (which is already a worrying symptom by itself). But inheritance mapping is not a “pretty way to avoid writing the same fields twice.” In ORM, inheritance is always a deal: you agree to additional complexity in exchange for polymorphism and domain honesty. If you do not need polymorphism, you are simply signing yourself up for a monthly payment in the form of SQL and migration complexity.

Let’s start with a down-to-earth symptom. Most often the idea of inheritance appears like this: you have two entities, they share 5–6 fields, and you feel sorry to copy and paste them. That is a very human desire, and there is no shame in it. But ORM is not a contest for “who wrote fewer lines”; ORM is about predictable behaviour and a correct model. If you choose inheritance only to eliminate duplication, you are optimizing the wrong place. Duplicated fields are a trifle compared to what you may get: strange queries, hard-to-index tables, non-obvious schema constraints, and the classic “why does it load like that?”

A simple rule is useful here: domain formulation first, technique second. So it helps to think as if @Inheritance does not exist yet. First we find out whether hierarchy is needed as part of the model. And only if the answer is “yes” does it make sense to discuss how exactly ORM will store it.

2. Checking is-a: a genuine subtype

In Java, inheritance is very easy to confuse with “they have the same fields, so it must be a base class.” But the domain model requires stricter logic. The main question is: is the relation between the types “is-a” or “has-a”? If you cannot honestly say the sentence “X is a Y” without feeling awkward, then inheritance is suspicious.

Imagine that you are modelling promo campaigns. “A percentage campaign is a promo campaign” sounds fine. “A fixed discount is a promo campaign” also sounds fine. But “An address is a customer” is already strange. “A category is a product” is even worse. That is not a hierarchy; those are different relations: association, composition, value object, link entity, anything but “is-a”.

There is a useful check for subtype honesty: the subtype must be substitutable for the base type without the code starting to do “and if it is that subtype, then...” and filling up with instanceof. In OOP, this is usually called the Liskov substitution principle, but it can be simpler: if your service accepts the base type, it should not have to guess every time which exact subtype it got.

Let’s look at this with a minimal hierarchy example without ORM, just as a domain idea:

import com.example.commerce.common.jpa.Money;

public abstract class PromotionCampaign {
    // Common campaign fields that every subtype has
    private String code;
    private boolean active;

    // Important: the base type defines the contract, and the subtype implements the specifics
    public abstract Money applyDiscount(Money originalPrice);

    // Assume the entity has activity control (for the service examples below)
    public void setActive(boolean active) {
        this.active = active;
    }
}

The basic meaning here is clear: a campaign has a code, it is active or inactive, and it can apply a discount. How exactly? That is up to the subtype. That is what “variants of one meaning” are, and that is why inheritance really exists.

To reinforce the intuition, it is useful to see not only correct examples, but also “almost correct” ones. Here is an example where “is-a” feels forced:

public class ProductWithDetails extends Product {
    // This looks like a "subtype", but in domain terms it is usually a "has-a":
    // a product has details, it is not "a product is details".
    private String description;
    private Integer warrantyMonths;
}

On paper it looks convenient, but in domain terms it is usually not true. ProductDetails is not a “subtype of product”; it is “product details”, meaning “has-a”. In our project, we actually made ProductDetails a separate entity, and that was a deliberate composition, not inheritance.

3. Signs of a living hierarchy

To keep this course from turning into a pile of personal taste, let’s formulate criteria. We will not turn this into a list of “10 commandments” (ORM already loves pseudo-religions), but we will build a stable decision-making model.

Inheritance is justified when you really have one common type that lives in the system as a concept, and its variants are not random deviations but stable subtypes. This is usually visible in the code and use cases. If you have a scenario like “get active campaigns” and then handle them the same way: deactivate them, check their validity period, apply them to a price, log them, and show them in the admin list — then the base type becomes useful.

A very helpful question is: are there polymorphic reads and polymorphic code? That is, will you have a place where you work with PromotionCampaign, not with a specific PercentPromotionCampaign. If not, inheritance turns into “schema for the sake of beauty” (and schema beauty without a connection to queries is a path to suffering).

Another good criterion: the subtype should add meaning, not just a column. If you added a subtype only for one nullable field, that is usually a signal that the model can be expressed more simply. A subtype wins when it has its own invariants, for example: a percentage discount must have percentValue, and a fixed discount must have discountAmount. These are genuinely different rules, and it is nice to keep them in different types instead of living in a “nullable kingdom”.

I will show this idea as a small decision-making diagram. Yes, it is a bit like an “interview in your head”, but it saves weeks of life.

flowchart TD
    A["There are 2+ entity variants"] --> B{"Is this one meaning? is-a"}
    B -->|no| C["Composition / association / value object / link entity"]
    B -->|yes| D{"Is there shared code / reading through the base type?"}
    D -->|no| E["Think: separate entities or one type with a type field"]
    D -->|yes| F{"Do the subtypes have different invariants/behaviour?"}
    F -->|no| G["Usually one type + a field is enough"]
    F -->|yes| H["Inheritance is justified (move on to ORM strategy)"]

Notice: on the diagram, inheritance is not the first answer, but a rather late one. And that is normal. In ORM, inheritance is like a kitchen knife: a useful tool, but if you try to use it for everything, you quickly begin to understand why chefs are so nervous.

4. The benefit of the base type: polymorphism

Inheritance in a model is not just about tables, it is about code. If the base type does not make the code simpler, it is almost always harmful. In a good hierarchy, you get the ability to write service methods that do not care which exact subtype arrived. They work against a common contract.

The simplest example from a daily plan is campaign deactivation. This is not “business magic”, but a perfectly honest operation: any campaign can be set to active=false.

public class PromotionCampaignService {

    public void deactivate(PromotionCampaign campaign) {
        // We work through the base type: the subtype does not matter
        campaign.setActive(false);
    }
}

Here the base type is useful because the operation does not depend on the discount type. It is a small thing, but it shows the idea: the base type has a common part of behaviour.

Now let’s show a more “tasty” example that reveals the power of subtypes: apply a discount to a price. We can make it so that the business code does not even know which campaign is in front of it — it just asks the campaign to apply itself to the price.

import com.example.commerce.common.jpa.Money;

public class PromotionEngine {

    public Money apply(PromotionCampaign campaign, Money originalPrice) {
        // Key idea: one call instead of if/switch by type
        return campaign.applyDiscount(originalPrice);
    }
}

That is a pleasant model: no if, no switch, no instanceof. There is one call. Each campaign is responsible for itself. And, importantly for the persistence layer, this also keeps the model from turning into an “anemic” one (where entities are just bags of fields and all the logic lives in services and if-else).

To feel the difference, let’s look at the “bad” version of the same code, where inheritance is there but polymorphism is not — and the service turns into a guessing game:

import com.example.commerce.common.jpa.Money;

public class PromotionEngineBad {

    public Money apply(PromotionCampaign campaign, Money price) {
        // Red flag: manual branching by subtype instead of polymorphism
        if (campaign instanceof PercentPromotionCampaign percent) {
            return percent.applyDiscount(price);
        }
        if (campaign instanceof FixedAmountPromotionCampaign fixed) {
            return fixed.applyDiscount(price);
        }

        // Dangerous: a new subtype will be added — and it will "silently" not be applied
        return price;
    }
}

Such a construct is a red flag. It says: “We have a hierarchy, but we do not trust it.” And that almost always means either the hierarchy is unnecessary, or the base contract is poorly designed.

5. Example: PromotionCampaign in Commerce Lab

In our project Commerce Persistence Lab, promo campaigns are intentionally designed as a limited inheritance case, not as a style of “let’s make everything hierarchical now.” This is important to emphasize: if you start “multiplying inheritance” on Product, Customer, and PurchaseOrder, you will very quickly turn the lab into a museum of suffering.

Let’s start with the domain form, still without ORM annotations. The base type describes what is common to any campaign: code, activity, and usually the validity period. I will keep the fields minimal so that the example stays clean.

import java.time.LocalDate;

public abstract class PromotionCampaign {
    // Campaign identifier in business terms (not necessarily the DB id)
    private String code;

    // Common flag: any campaign can be enabled or disabled
    private boolean active;

    // Common validity period (if the model needs it)
    private LocalDate validFrom;
    private LocalDate validTo;
}

Then subtypes appear, each with its own special part. The percentage campaign contains a percentage.

public class PercentPromotionCampaign extends PromotionCampaign {
    // Subtype invariant: the percentage exists only here, not as "nullable in a common table"
    private Integer percentValue;
}

The fixed campaign contains a discount amount. And here Money fits in nicely, which we built earlier as an @Embeddable value object (that is, we continue developing one and the same codebase, rather than drawing abstract classes in a vacuum).

import com.example.commerce.common.jpa.Money;

public class FixedAmountPromotionCampaign extends PromotionCampaign {
    // Subtype invariant: the fixed discount exists only here
    private Money discountAmount;
}

Even at this level, it is clear why subtypes are useful. They let you express model constraints more honestly. A percentage discount without a percentage is not “just null”; it is a broken entity. A fixed discount without an amount is broken too. If, instead of inheritance, you make one class with percentValue and discountAmount fields, you create a state where “both are null” — and then heroically chase bugs at runtime.

Now let’s add behaviour. I will intentionally make the implementation as educational and short as possible, without turning Money into a mini accounting system. The important thing is to see the principle itself: the subtype implements its part.

import com.example.commerce.common.jpa.Money;

public class PercentPromotionCampaign extends PromotionCampaign {

    @Override
    public Money applyDiscount(Money originalPrice) {
        // Here should be the logic "multiply by (100 - percent) / 100"
        // We are leaving a stub for now, because the example is about responsibility distribution
        return originalPrice; // simplified for the example
    }
}
import com.example.commerce.common.jpa.Money;

public class FixedAmountPromotionCampaign extends PromotionCampaign {

    @Override
    public Money applyDiscount(Money originalPrice) {
        // Here should be the logic "originalPrice - discountAmount" with checks
        // (for example, are we going below zero?)
        return originalPrice; // simplified for the example
    }
}

Yes, these are “stubs”, because the real Money math was already formalized in the previous days (and you definitely did it more carefully than I am doing in five lines right now). We are not talking about money now; we are talking about who is responsible for the behaviour.

And now the base type really starts working as a contract. In code, you can keep a list of campaigns under one type:

import java.util.List;

public class PromotionCampaignRegistry {
    // Stored as one list: this is "polymorphic storage/reading"
    private final List<PromotionCampaign> campaigns;

    public PromotionCampaignRegistry(List<PromotionCampaign> campaigns) {
        this.campaigns = campaigns;
    }
}

That seems trivial, but there is an important idea here: if you have a place where you store or return campaigns as PromotionCampaign, then you really need polymorphism. And it is exactly such places that later heavily influence which ORM mapping makes sense.

Alternatives to inheritance

Even if you honestly passed the “is-a” check, inheritance still does not have to be the only solution. In practice, you almost always have at least three options: separate entities without a common base type, one class with a type field and optional fields, and finally a hierarchy.

To avoid getting ahead of ourselves, let us fix only the meaning: you choose the model not by aesthetics, but by how services and queries will use it. If you never read “all campaigns together” and always work separately with percentage and fixed ones, the base type may simply be unnecessary. If, however, you have a common list of campaigns and a common mechanism for applying them to an order, the base type starts to bring value.

Approach When it seems reasonable What most often starts hurting
One class + type + nullable fields Few variants, minimal differences, logic is almost the same Invalid states (everything null), lots of if, hard-to-maintain invariants
Two separate entities without a base Almost no common use cases, different lifecycle rules Duplication in code and queries, harder to build one list
Inheritance There is a common meaning and common polymorphic code/reading The cost of mapping and SQL becomes part of your life

The main idea: inheritance is not “more correct”, it is “more expensive, but sometimes more honest”. And if the model’s honesty really benefits the code and queries, the price may be justified.

6. Typical inheritance mistakes

Inheritance has a special trap: it looks “smart” simply by existing. A class like AbstractSomething inspires respect, even if inside it is just a warehouse of random fields. That is why the mistakes here are very typical and repeat across projects with enviable stability — like N+1, only without the SQL log (although the SQL log will come later too).

Mistake #1: building a hierarchy only to remove duplicated fields.
This is the most common reason for inheritance and one of the weakest. Duplicating a couple of fields is almost always cheaper than the consequences of a complex ORM hierarchy. A good test is to ask yourself: does the base type have its own role in code and queries? If the base type is used nowhere except “so the fields are not repeated”, then it is not a base; it is a warehouse.

Mistake #2: moving everything into the base type “just in case.”
That is how you get monsters like BaseEntity with 20 fields, half of which apply only to some subtypes. In the end, subtypes become “obliged to live” with someone else’s fields, and the model stops expressing meaning. The base type should contain only what is truly common to all variants and does not break their invariants.

Mistake #3: building a deep hierarchy in advance.
In a training project and in real backend work, deep hierarchies almost always create more problems than benefits. The more levels there are, the harder it is to understand where the state is, how it gets updated, and how that is reflected in the schema. A practical approach is to keep inheritance flat and narrow: one base type and 2–3 subtypes, no more.

Mistake #4: “inheritance exists, but polymorphism does not.”
If you are writing if (...) and instanceof everywhere, then the base contract is not working. That is either a sign of a poor base type design, or a sign that you do not need a hierarchy at all and need a different model instead (for example, composition or separate entities). Inheritance without polymorphism is like a bicycle that you carry in your hands: formally transport, practically a dumbbell.

Mistake #5: mixing objects with different lifecycles in one hierarchy.
The subtypes of one hierarchy should be of the “same class of life”: similar activity rules, roughly the same lifecycle, similar read and update scenarios. If one subtype lives “for a minute and is deleted”, while another lives “for years and is audited”, that is a warning sign. ORM will then honestly try to represent this as one model — and you will get strange compromises.

1
Zadanie
Hibernate deep-dive, poziom 16, lekcja 0
Niedostępne
Polimorficzne dezaktywowanie kampanii
Polimorficzne dezaktywowanie kampanii
1
Zadanie
Hibernate deep-dive, poziom 16, lekcja 0
Niedostępne
Ogólne przetwarzanie listy kampanii przez typ bazowy
Ogólne przetwarzanie listy kampanii przez typ bazowy
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION