Magnus' blog


Modelling Rich Domains with Generic Datamodels in Hibernate

This post aims to show a solution to a central problem in many business-complex domains: How do you store an ever-changing domain model in a way that allows you to easily see the previous states? It proposes a solution that uses a Rich Domain for easily modelling and testing of the actual code, while keeping the datamodel in a generic structure to support later reading of the code. It uses the example of a Receipt, but it applies to many problem spaces. I have had the most use of it in calculation-heavy domains, where storing an intermediate state is useful in reasoning about how a result was created.

The source code is available on https://github.com/HHMagnus/Rich-Domain-Generic-Datamodel-Java.

Rich Domain

The example of the domain is BusinessReceipt and PersonalReceipt with the following (simplified) code:

public record Address(String addressLine) { }
public record Period(LocalDate startDate, LocalDate endDate) { }
public record SubscriptionMonth(int value) { }

public record BusinessReceipt(
    Address invoiceAddress,
    Period subscriptionPeriod,
    boolean isRenewal,
    SubscriptionMonth months
) { }

public record PersonalReceipt (
        Address deliveryAddress,
        Address invoiceAddress,
        LocalDate date
) { }

This is a very simple structure where any domain logic is omitted for brevity.

Contract

In order to interface with a generic model, a few contracts are needed. The model needs to know the enum type of both the Receipt and the specific field:

public enum ReceiptType {
    BUSINESS,
    PERSONAL
}

public enum Field {
    INVOICE_ADDRESS_LINE,
    DELIVERY_ADDRESS_LINE,
    DATE,
    SUBSCRIPTION_PERIOD_START_DATE,
    SUBSCRIPTION_PERIOD_END_DATE,
    IS_RENEWAL,
    SUBSCRIPTION_MONTHS;
}

Field stores all possible fields. This is the structure that can be expanded in the future when new fields are added or removed.

In order to save the domain, two additional interfaces are used to specify how to store it:

public interface Receipt {
    ReceiptType save(ReceiptStorage storage);
}
public interface ReceiptStorage {
    void text(Field field, String value);
    void number(Field field, int value);
    void bool(Field field, boolean value);
    void date(Field field, LocalDate date);
}

These allow the domain to specify how to save the data it contains. They are then implemented by the domain classes:

// BusinessReceipt
public ReceiptType save(final ReceiptStorage storage) {
    storage.text(Field.INVOICE_ADDRESS_LINE, invoiceAddress().addressLine());
    storage.date(Field.SUBSCRIPTION_PERIOD_START_DATE, subscriptionPeriod().startDate());
    storage.date(Field.SUBSCRIPTION_PERIOD_END_DATE, subscriptionPeriod().endDate());
    storage.bool(Field.IS_RENEWAL, isRenewal());
    storage.number(Field.SUBSCRIPTION_MONTHS, months.value());

    return ReceiptType.BUSINESS;
}

// PersonalReceipt
public ReceiptType save(final ReceiptStorage storage) {
    storage.text(Field.DELIVERY_ADDRESS_LINE, deliveryAddress().addressLine());
    storage.text(Field.INVOICE_ADDRESS_LINE, invoiceAddress().addressLine());
    storage.date(Field.DATE, date());

    return ReceiptType.PERSONAL;
}

The interface needs to be expanded for any possible type.

Generic Datamodel

The generic datamodel contains two entities: Instance and InstanceField. The Instance represents a Receipt and the InstanceField is a list of all the fields. The Instance class simply contains the ReceiptType and the field list:

@Entity
@Table(name = "instance")
public final class Instance {
    @Id
    @GeneratedValue
    private Long id;

    @Column
    @Enumerated(EnumType.STRING)
    public ReceiptType type;

    @OneToMany(mappedBy = "instance", cascade = CascadeType.ALL, orphanRemoval = true)
    public List<InstanceField> fields = new ArrayList<>();
}

InstanceField is an abstract class that uses inheritance to structure the different value types using BooleanField, DateField, NumberField, and TextField:

@Entity
@Table(name = "instance_field")
@DiscriminatorColumn(name = "field_type")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@ConcreteProxy
public abstract sealed class InstanceField
        permits BooleanField, DateField, NumberField, TextField {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "instance_id")
    private Instance instance;

    @Column
    @Enumerated(EnumType.STRING)
    private Field field;

    protected InstanceField(
            Field field,
            Instance instance
    ) {
        this.field = field;
        this.instance = instance;
    }

    protected InstanceField() { }

    public Field field() {
        return field;
    }
}

BooleanField

@Entity
@DiscriminatorValue("boolean")
public final class BooleanField extends InstanceField {
    @Column(name = "boolean_value")
    public Boolean value;

    public BooleanField(
            Field field,
            Instance instance,
            Boolean value
    ) {
        super(field, instance);
        this.value = value;
    }

    // No args constructor required by Hibernate
    protected BooleanField() {
        super();
    }

    public Boolean value() {
        return value;
    }
}

DateField

@Entity
@DiscriminatorValue("date")
public final class DateField extends InstanceField {
    @Column(name = "date_value")
    public LocalDate value;

    public DateField(
            Field field,
            Instance instance,
            LocalDate value
    ) {
        super(field, instance);
        this.value = value;
    }

    // No args constructor required by Hibernate
    protected DateField() {
        super();
    }

    public LocalDate value() {
        return value;
    }
}

NumberField

@Entity
@DiscriminatorValue("number")
public final class NumberField extends InstanceField {
    @Column(name = "number_value")
    public Integer value;

    public NumberField(
            Field field,
            Instance instance,
            Integer value
    ) {
        super(field, instance);
        this.value = value;
    }

    // No args constructor required by Hibernate
    protected NumberField() {
        super();
    }

    public Integer value() {
        return value;
    }
}

TextField

@Entity
@DiscriminatorValue("text")
public final class TextField extends InstanceField {
    @Column(name = "text_value")
    public String value;

    public TextField(
            Field field,
            Instance instance,
            String value
    ) {
        super(field, instance);
        this.value = value;
    }

    // No args constructor required by Hibernate
    protected TextField() {
        super();
    }

    public String value() {
        return value;
    }
}

This data structure uses a SINGLE_TABLE inheritance strategy, which means the SQL schema only contains two tables:

create table instance (
	id bigint not null primary key,
	type varchar(255) check ((type in ('BUSINESS','PERSONAL')))
);

create table instance_field (
	id bigint generated by default as identity primary key,
	instance_id bigint references instance,
	
	field_type varchar(31) not null check ((field_type in ('boolean','number','date','text'))),
	field varchar(255) check ((field in ('INVOICE_ADDRESS_LINE','DELIVERY_ADDRESS_LINE','DATE','SUBSCRIPTION_PERIOD_START_DATE','SUBSCRIPTION_PERIOD_END_DATE','IS_RENEWAL','SUBSCRIPTION_MONTHS'))),

	boolean_value boolean,
	date_value date,
	number_value integer,
	text_value varchar(255)
);

In order to construct the Instance, it implements the ReceiptStorage interface and has the following static factory method Instance.of(Receipt receipt):

// Instance class
public static Instance of(final Receipt receipt) {
    final var instance = new Instance();
    instance.type = receipt.save(instance);
    return instance;
}

@Override
public void text(final Field field, final String value) {
    final var textField = new TextField(field, this, value);
    fields.add(textField);
}

@Override
public void number(final Field field, final int value) {
    final var numberField = new NumberField(field, this, value);
    fields.add(numberField);
}

@Override
public void bool(final Field field, final boolean value) {
    final var boolField = new BooleanField(field, this, value);
    fields.add(boolField);
}

@Override
public void date(final Field field, final LocalDate date) {
    final var dateField = new DateField(field, this, date);
    fields.add(dateField);
}

This allows for quickly saving the domain model and can easily be expanded.

Reading

A read model is created to expose later:

public record ReceiptFieldModel(Field field, String displayValue) { }

public record ReceiptModel(
        ReceiptType type,
        List<ReceiptFieldModel> fields
) {
    public static ReceiptModel of(final Instance instance) {
        final var fields = instance.fields()
                .stream()
                .map(field -> switch (field) {
                    case BooleanField booleanField -> new ReceiptFieldModel(booleanField.field(), booleanField.value.toString());
                    case DateField dateField -> new ReceiptFieldModel(dateField.field(), dateField.value.toString());
                    case NumberField numberField -> new ReceiptFieldModel(numberField.field(), numberField.value.toString());
                    case TextField textField -> new ReceiptFieldModel(textField.field(), textField.value);
                }).toList();

        return new ReceiptModel(instance.type(), fields);
    }
}

The sealed part of InstanceField now comes in handy, as it allows for switching over the possible values of the field without a default case. This will give a compile error if it breaks later.

Combining It

In a real application, some business logic will create the Receipt, which can then be stored and even later read. This process is most visible in the ReceiptTest from the code:

@Test
void canSaveAndLaterReadPersonalReceipt() {
    // Arrange
    final var invoiceAddress = "Invoice address";
    final var startDate = LocalDate.of(2026, 1, 1);
    final var endDate = LocalDate.of(2026, 1, 31);
    final var isRenewal = true;
    final var months = 15;

    final var businessReceipt = new BusinessReceipt(
            new Address(invoiceAddress),
            new Period(startDate, endDate),
            isRenewal,
            new SubscriptionMonth(months)
    );

    // Act (persist)
    final var instance = Instance.of(businessReceipt);

    entityManager.persist(instance);

    sync();

    // Assert (read to model)
    final var retrieved = entityManager.find(Instance.class, instance.id());
    final var recipientModel = ReceiptModel.of(retrieved);

    assertEquals(ReceiptType.BUSINESS, recipientModel.type());
    assertEquals(List.of(
            new ReceiptFieldModel(Field.INVOICE_ADDRESS_LINE, invoiceAddress),
            new ReceiptFieldModel(Field.SUBSCRIPTION_PERIOD_START_DATE, startDate.toString()),
            new ReceiptFieldModel(Field.SUBSCRIPTION_PERIOD_END_DATE, endDate.toString()),
            new ReceiptFieldModel(Field.IS_RENEWAL, "" + isRenewal),
            new ReceiptFieldModel(Field.SUBSCRIPTION_MONTHS, "" + months)
    ), recipientModel.fields());
}

From this it is also visible that if the domain ever changes, the code here can be updated, but reading the old domain will still be possible as no structure is enforced when changing.

Conclusion

This example successfully shows that a rich domain model can be persisted and later read by a generic datamodel. This allows a domain to change over time while still allowing previous states to be read in a consistent manner. The code can move forward while still supporting a historical view of the data. This is most useful when storing data that is append-only and never modified. This example can be the basis of other business-complex problem solutions, and the Receipt example is simply one of many possible domains.

The source code is available on https://github.com/HHMagnus/Rich-Domain-Generic-Datamodel-Java.

View next or previous post: