evita-schema-change

star 67

Use when adding a new field, enum, or configuration option to any evitaDB schema type (ReferenceSchema, EntitySchema, AttributeSchema, etc.). Provides a comprehensive 8-layer recipe covering contracts, DTOs, builders, mutations, external APIs (gRPC/GraphQL/REST) with backward compatibility, Kryo serializers, and WAL serializers.

FgForrest By FgForrest schedule Updated 3/27/2026

name: evita-schema-change description: Use when adding a new field, enum, or configuration option to any evitaDB schema type (ReferenceSchema, EntitySchema, AttributeSchema, etc.). Provides a comprehensive 8-layer recipe covering contracts, DTOs, builders, mutations, external APIs (gRPC/GraphQL/REST) with backward compatibility, Kryo serializers, and WAL serializers.

evitaDB Schema Change Recipe

Overview

This skill is a step-by-step recipe for adding a new field or configuration option to any evitaDB schema type. It covers all 8 layers that must be modified, generalized from multiple reference implementations in the codebase.

When to Use

  • Adding a new field of any type (boolean, enum, string, scoped collection, etc.) to any schema type
  • Extending schema mutations with new parameters
  • Any change that must propagate through contracts -> DTOs -> builders -> mutations -> external APIs -> serializers

Field Type Variants

The recipe adapts based on the type of field being added:

Field Type Contract Getter DTO Storage Mutation Field Serialization
Simple boolean boolean isNewField() boolean newField boolean newField output.writeBoolean()
Simple enum NewEnum getNewField() NewEnum newField NewEnum newField kryo.writeObject()
Nullable value @Nullable T getNewField() @Nullable T newField @Nullable T newField boolean flag + value
Scope-aware flag three-tier getters (see below) Map<Scope, T> ScopedNewField[] array of scope+value pairs
Scope-aware collection three-tier getters (see below) Map<Scope, Set<NewEnum>> ScopedNewField[] nested scope+set pairs

Each layer section below shows the scope-aware collection pattern (most complex) with callouts for simpler variants.

Notation Conventions

Placeholder Meaning Example
NewField / newField The new field being added indexedComponents
NewEnum A new enum type (if applicable) ReferenceIndexedComponents
ScopedNewField Scoped wrapper record (if scope-aware) ScopedReferenceIndexedComponents
SchemaType The schema type being extended ReferenceSchema, AttributeSchema
SchemaTypeContract The schema contract interface ReferenceSchemaContract
YYYY_M Version identifier for backward compat 2026_2

Layer 1: Schema Contracts & Editors

Module: evita_api Package: io.evitadb.api.requestResponse.schema

1.1 Define a New Enum (if applicable)

Only needed when the field introduces a new enumeration. Skip for boolean/string fields.

public enum NewEnum {
    /** Description of first value. */
    VALUE_ONE,
    /** Description of second value. */
    VALUE_TWO
}

1.2 Add Getters to the Schema Contract

For scope-aware fields — use the three-tier getter pattern (default scope -> specific scope -> all scopes):

@Nonnull
default Set<NewEnum> getNewField() {
    return getNewField(Scope.DEFAULT_SCOPE);
}

@Nonnull
Set<NewEnum> getNewField(@Nonnull Scope scope);

@Nonnull
Map<Scope, Set<NewEnum>> getNewFieldInScopes();

For simple fields — a single getter suffices:

// Boolean:
boolean isNewField();

// Enum:
@Nonnull
NewEnum getNewField();

// Nullable:
@Nullable
String getNewField();

1.3 Add Editor Methods

Fluent methods returning the builder type T:

// Scope-aware:
@Nonnull
default T withNewField(@Nonnull NewEnum... values) {
    return withNewFieldInScope(Scope.DEFAULT_SCOPE, values);
}

@Nonnull
T withNewFieldInScope(@Nonnull Scope scope, @Nonnull NewEnum... values);

// Simple boolean:
@Nonnull
T withNewField();

@Nonnull
T withoutNewField();

1.4 Reflected Reference Schema (ReferenceSchema changes only)

This step applies only when modifying ReferenceSchema, because ReflectedReferenceSchema inherits settings from its target reference. Other schema types do not have a reflected variant.

// In ReflectedReferenceSchemaContract.java:
boolean isNewFieldInherited();

// In ReflectedReferenceSchemaEditor.java:
@Nonnull
S withNewFieldInherited();

Checklist

  • New enum created (if applicable)
  • Getters added to SchemaTypeContract (three-tier for scope-aware, single for simple)
  • Editor fluent methods added to SchemaTypeEditor
  • @Nonnull / @Nullable annotations on all parameters and return types
  • JavaDoc with Markdown formatting on all new methods
  • (ReferenceSchema only) isNewFieldInherited() in ReflectedReferenceSchemaContract
  • (ReferenceSchema only) withNewFieldInherited() in ReflectedReferenceSchemaEditor

Layer 2: Schema DTOs

Module: evita_api Package: io.evitadb.api.requestResponse.schema.dto

2.1 Add Field and Constructor Parameter

// Scope-aware:
protected final Map<Scope, Set<NewEnum>> newFieldInScopes;
// In constructor:
this.newFieldInScopes = CollectionUtils.toUnmodifiableMap(newFieldInScopes);

// Simple:
private final boolean newField;

2.2 Implement Contract Getters

// Scope-aware:
@Nonnull
@Override
public Set<NewEnum> getNewField(@Nonnull Scope scope) {
    final Set<NewEnum> values = this.newFieldInScopes.get(scope);
    return values != null ? values : Collections.emptySet();
}

@Nonnull
@Override
public Map<Scope, Set<NewEnum>> getNewFieldInScopes() {
    return this.newFieldInScopes;
}

2.3 Static Converter Methods (scope-aware fields only)

For scope-aware fields, add conversion helpers on the DTO class:

  • toNewFieldEnumMap(ScopedNewField[]) — converts scoped array to Map<Scope, Set<NewEnum>>
  • defaultNewField(Map<Scope, ...>) — creates default values for scopes that need them
  • resolveNewField(ScopedNewField[], ...) — resolves explicit array or falls back to defaults

These follow the pattern of existing methods like toReferenceIndexEnumMap() and resolveIndexedComponents() in ReferenceSchema.java.

2.4 Update _internalBuild() Overloads

Add the new parameter to all _internalBuild() overloads. There are typically two forms:

// Form 1: Array/primitive-based (used by mutations and serializers)
@Nonnull
public static SchemaType _internalBuild(
    // ... existing params ...
    @Nullable ScopedNewField[] newFieldInScopes,  // scope-aware
    // OR: boolean newField,                       // simple
    // ... remaining params ...
)

// Form 2: Map/final-type-based (used by internal construction)
@Nonnull
public static SchemaType _internalBuild(
    // ... existing params ...
    @Nonnull Map<Scope, Set<NewEnum>> newFieldInScopes,  // scope-aware
    // OR: boolean newField,                               // simple
    // ... remaining params ...
)

2.5 Reflected Reference Schema DTO (ReferenceSchema changes only)

In ReflectedReferenceSchema.java, add inheritance resolution:

private final boolean newFieldInherited;

// In constructor:
this.newFieldInherited = newFieldInScopes == null;

Add a private static resolution method that checks: explicit value -> inherited from reflected reference -> default fallback.

2.6 Update equals() / hashCode() / toString()

Include the new field in all three methods on the DTO.

Checklist

  • Field added to DTO with immutable wrapping
  • Contract getter methods implemented
  • Static converter/resolver methods added (scope-aware fields)
  • Both _internalBuild() overloads updated
  • All callers of _internalBuild() updated — grep for _internalBuild( across the entire evita_api module; every call that reconstructs the schema must pass the new parameter (see Pitfall #10)
  • equals() / hashCode() / toString() updated
  • (ReferenceSchema only) Reflected DTO inheritance resolution added

Layer 3: Builders

Module: evita_api Package: io.evitadb.api.requestResponse.schema.builder

3.1 Implement Editor Method

Builders accumulate mutations — they do NOT store the field directly:

@Nonnull
@Override
public SchemaTypeBuilder withNewFieldInScope(
    @Nonnull Scope scope,
    @Nonnull NewEnum... values
) {
    this.updatedSchemaDirty = updateMutationImpact(
        this.updatedSchemaDirty,
        addMutations(
            this.catalogSchema, this.entitySchema, this.mutations,
            new SetSchemaTypeNewFieldMutation(
                getName(),
                /* ... existing related fields ... */,
                new ScopedNewField[]{
                    new ScopedNewField(scope, values)
                }
            )
        )
    );
    return this;
}

3.2 Update CreateMutation Initialization

In the builder constructor (if (createNew) block), pass null for the new field to indicate defaults:

this.mutations.add(
    new CreateSchemaTypeSchemaMutation(
        /* ... existing args ... */,
        null,  // newFieldInScopes — null means use defaults
        /* ... remaining args ... */
    )
);

3.3 Reflected Builder (ReferenceSchema changes only)

@Nonnull
@Override
public ReflectedReferenceSchemaBuilder withNewFieldInherited() {
    this.updatedSchemaDirty = updateMutationImpact(
        this.updatedSchemaDirty,
        addMutations(
            this.catalogSchema, this.entitySchema, this.mutations,
            new SetSchemaTypeNewFieldMutation(
                getName(),
                /* ... */,
                (ScopedNewField[]) null  // null = inherited
            )
        )
    );
    return this;
}

Checklist

  • Editor method implemented — creates mutation, does NOT store field
  • CreateMutation initialization passes null for new field (defaults)
  • Builder returns this for fluent chaining
  • (ReferenceSchema only) Reflected builder withNewFieldInherited()

Layer 4: Mutations

Module: evita_api Package: io.evitadb.api.requestResponse.schema.mutation.*

4.1 Create Scoped Wrapper Record (scope-aware fields only)

If the new field is scope-aware, create a ScopedNewField record:

public record ScopedNewField(
    @Nonnull Scope scope,
    @Nonnull NewEnum[] values
) implements Serializable {

    public static final ScopedNewField[] EMPTY = new ScopedNewField[0];

    public ScopedNewField {
        Assert.notNull(scope, "Scope must not be null");
        Assert.notNull(values, "Values must not be null");
    }

    // IMPORTANT: Override equals/hashCode because arrays use reference equality
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof ScopedNewField that)) return false;
        return scope == that.scope && Arrays.equals(values, that.values);
    }

    @Override
    public int hashCode() {
        int result = scope.hashCode();
        result = 31 * result + Arrays.hashCode(values);
        return result;
    }

    @Override
    public String toString() {
        return "ScopedNewField[scope=" + scope +
            ", values=" + Arrays.toString(values) + ']';
    }
}

4.2 Update Create Mutation

Add the field to both constructor overloads:

@Getter @Nullable private final ScopedNewField[] newFieldInScopes;
// OR for simple: @Getter private final boolean newField;

// Simple constructor chains to full constructor with null/default:
this(/* ... */, null, /* ... */);

// Full constructor (@SerializableCreator):
this.newFieldInScopes = newFieldInScopes;

Update mutate() to pass the field to _internalBuild().

Update combineWith() to add a makeMutationIfDifferent() call comparing the new field.

4.3 Create or Update Set Mutation

Add the new field as a parameter to the set mutation. Follow the constructor hierarchy pattern (simple -> detailed -> full with @SerializableCreator).

4.4 Mutation Combination — Merging

When combining mutations with the same name, merge scoped fields by putting newer scope entries over older ones. For null (inherited), let null win as the latest mutation.

4.5 Reflected Change Detection (ReferenceSchema changes only)

private boolean hasNewFieldChanged(@Nonnull ReflectedReferenceSchema schema) {
    if (this.newFieldInScopes == null) {
        return !schema.isNewFieldInherited();
    }
    if (schema.isNewFieldInherited()) {
        return true;
    }
    return !schema.getNewFieldInScopes().equals(
        SchemaType.toNewFieldEnumMap(this.newFieldInScopes)
    );
}

Checklist

  • Scoped wrapper record created (if scope-aware) with EMPTY constant, validation, custom equals/hashCode
  • Create mutation updated — both constructors, field, mutate(), combineWith()
  • Set mutation updated (or created) with new field parameter
  • Mutation combination properly merges fields
  • (ReferenceSchema only) Create reflected mutation updated
  • (ReferenceSchema only) Reflected change detection implemented

Layer 5: External API Core — Descriptors & Converters

Module: evita_external_api_core Package: io.evitadb.externalApi.api.catalog.schemaApi.model

5.1 Create Scoped Descriptor (scope-aware fields only)

For scope-aware fields, create ScopedNewFieldDescriptor.java extending ScopedDataDescriptor:

public interface ScopedNewFieldDescriptor extends ScopedDataDescriptor {
    PropertyDescriptor VALUES = PropertyDescriptor.builder()
        .name("values")
        .description(/* ... */)
        .type(nonNull(NewEnum[].class))
        .build();

    ObjectDescriptor THIS = ObjectDescriptor.builder()
        .name("ScopedNewField")
        .staticProperties(List.of(SCOPE, VALUES))
        .build();

    ObjectDescriptor THIS_INPUT = ObjectDescriptor.from(THIS)
        .name("InputScopedNewField")
        .build();
}

5.2 Add PropertyDescriptor to Schema Descriptor

// In SchemaTypeDescriptor.java:
PropertyDescriptor NEW_FIELD = PropertyDescriptor.builder()
    .name("newField")
    .description(/* ... */)
    .type(/* nonNull(Boolean.class) or nonNullListRef(ScopedNewFieldDescriptor.THIS) */)
    .build();

5.3 Add to Mutation Descriptors

Add PropertyDescriptor to each mutation descriptor (Create*Descriptor, Set*Descriptor) with both output and input variants (using PropertyDescriptor.from() for the input variant).

5.4 Update Mutation Converters

For scope-aware fields, use PropertyObjectListMapper to deserialize nested structures:

final ScopedNewField[] newFieldInScopes = input.getOptionalProperty(
    MutationDescriptor.NEW_FIELD_IN_SCOPES.name(),
    new PropertyObjectListMapper<>(
        getMutationName(),
        getExceptionFactory(),
        MutationDescriptor.NEW_FIELD_IN_SCOPES,
        ScopedNewField.class,
        nestedInput -> new ScopedNewField(
            nestedInput.getProperty(ScopedDataDescriptor.SCOPE),
            nestedInput.getProperty(ScopedNewFieldDescriptor.VALUES)
        )
    )
);

For simple fields, use direct input.getProperty() or input.getOptionalProperty().

Important — getOptionalProperty() overloads: When reading array-typed properties (e.g., Scope[]), always use the typed overload input.getOptionalProperty(name, Class) — not the raw input.getOptionalProperty(name). The raw overload returns the underlying List without type conversion, causing ClassCastException when the code expects an array. Example:

// WRONG — returns raw List, will ClassCast to Scope[]:
final Scope[] scopes = input.getOptionalProperty(FACETED_IN_SCOPES.name());

// CORRECT — invokes toTargetType() -> toArrayOfSpecificType():
final Scope[] scopes = input.getOptionalProperty(FACETED_IN_SCOPES.name(), Scope[].class);

5.5 Custom Output Serialization for Non-Primitive Types

The base MutationConverter.convertObjectToOutput() uses reflection over @SerializableCreator constructor parameters and Output.toSerializableValue() for each value. If any constructor parameter type is not natively supported by toSerializableValue() (e.g., Expression, custom domain objects), the reflection-based output will throw.

To handle this, override convertToOutput(M mutation, Output output) in the converter to pre-set properties with custom serialization before calling super.convertToOutput(). Pre-set properties are skipped by the reflection logic (line ~167 of MutationConverter):

@Override
protected void convertToOutput(
    @Nonnull MyMutation mutation,
    @Nonnull Output output
) {
    // Pre-serialize the non-primitive field BEFORE super call
    final ScopedFacetedPartially[] partially = mutation.getFacetedPartiallyInScopes();
    if (partially != null) {
        final List<Map<String, Object>> serialized = new ArrayList<>(partially.length);
        for (ScopedFacetedPartially entry : partially) {
            final Map<String, Object> entryMap = new LinkedHashMap<>(2);
            entryMap.put("scope", entry.scope());
            final Expression expr = entry.expression();
            entryMap.put("expression", expr != null ? expr.toExpressionString() : null);
            serialized.add(entryMap);
        }
        output.setProperty("facetedPartiallyInScopes", serialized);
    }
    super.convertToOutput(mutation, output);
}

Tip: If multiple converters need the same custom serialization, extract it as a protected static helper in the shared base class (e.g., ReferenceSchemaMutationConverter) to avoid code duplication.

Checklist

  • Scoped descriptor created (if scope-aware) with THIS and THIS_INPUT
  • PropertyDescriptor added to schema descriptor
  • Mutation descriptors updated (both output and input variants)
  • Mutation converters updated with appropriate deserialization
  • Typed getOptionalProperty(name, Class) overload used for array properties (not raw overload)
  • convertToOutput() overridden for non-primitive types not supported by Output.toSerializableValue()

Layer 6: External APIs

Important — Backward Compatibility: All external API changes must be additive (non-breaking). New fields must be optional or have sensible defaults so that older clients continue to work. Specifically:

  • gRPC: New proto fields are optional by default in proto3. Never reuse field numbers. Old clients simply ignore unknown fields.
  • GraphQL: New fields in output types are non-breaking. New fields in input types must be nullable/optional so existing mutations keep working.
  • REST: New JSON fields in responses are non-breaking. New fields in request bodies must be optional with server-side defaults.

6a: gRPC

Module: evita_external_api_grpc

Proto Definitions

Add the new field to the relevant .proto files. Use the next available field number — never reuse old numbers, even for removed fields.

For a new enum, add to GrpcEnums.proto:

enum GrpcNewEnum {
    NEW_ENUM_VALUE_ONE = 0;
    NEW_ENUM_VALUE_TWO = 1;
}

For a scoped wrapper, add to GrpcEvitaDataTypes.proto:

message GrpcScopedNewField {
    GrpcEntityScope scope = 1;
    repeated GrpcNewEnum values = 2;
}

Add to schema and mutation messages in the appropriate .proto files. For simple booleans, use bool or google.protobuf.BoolValue (for nullable booleans).

After editing protos, regenerate Java stubs (build the evita_external_api_grpc module).

EvitaEnumConverter (if new enum)

Add bidirectional conversion methods. The toEvita() direction must handle UNRECOGNIZED:

@Nonnull
public static NewEnum toNewEnum(@Nonnull GrpcNewEnum grpc) {
    return switch (grpc) {
        case NEW_ENUM_VALUE_ONE -> NewEnum.VALUE_ONE;
        case NEW_ENUM_VALUE_TWO -> NewEnum.VALUE_TWO;
        case UNRECOGNIZED ->
            throw new EvitaInvalidUsageException("Unrecognized: " + grpc);
    };
}

EntitySchemaConverter and Mutation Converters

Update EntitySchemaConverter and gRPC mutation converters to map the new field between gRPC and domain types. For scope-aware fields, the pattern uses addAll* on the builder and stream mapping for the reverse direction.

Backward compatibility note: gRPC repeated fields default to empty lists, and new bool fields default to false. Converters must handle these defaults gracefully — empty list should map to null (not provided), not to an empty configuration.

6b: GraphQL

Module: evita_external_api_graphql

  1. Type registration in Catalog API — register ScopedNewFieldDescriptor.THIS and THIS_INPUT in CommonEvitaSchemaSchemaBuilder
  2. Type registration in System API — register ScopedNewFieldDescriptor.THIS in SystemGraphQLSchemaBuilder. This is a separate GraphQL schema builder for the system-level API (health checks, catalog management, CDC subscriptions). It maintains its own list of registered types independently of the catalog schema builder. Missing this causes graphql.AssertException: type ... not found in schema errors in EvitaServerTest and all SystemGraphQL* tests.
  3. DataFetcher — for scope-aware fields, create a singleton DataFetcher that converts Map<Scope, Set<NewEnum>> to List<ScopedNewField> for GraphQL output. For simple fields, no DataFetcher is needed — GraphQL Java resolves them from getters automatically.
  4. Register DataFetcher in EntitySchemaSchemaBuilder (or the appropriate schema builder)

Backward compatibility note: New output fields are non-breaking. New input fields must be optional so existing mutations without the field still parse.

6c: REST

Module: evita_external_api_rest

  1. Schema type registration in EntitySchemaObjectBuilder — registers scoped descriptor types (e.g., ScopedNewFieldDescriptor.THIS and THIS_INPUT) as reusable OpenAPI component schemas. This is for the schema output/input types, not for mutations.

  2. Mutation registration in CatalogRestBuilder — when adding a new mutation class, it must be registered in three methods:

    • buildMutationInterface() — add typeRefTo(SetSchemaTypeNewFieldMutationDescriptor.THIS.name()) to the mutation union discriminator
    • buildInputMutations() — add SetSchemaTypeNewFieldMutationDescriptor.THIS_INPUT to register the input mutation schema
    • buildOutputMutations() — add SetSchemaTypeNewFieldMutationDescriptor.THIS to register the output mutation schema
  3. Mutation registration in SystemRestBuilder — the system REST API has its own builder with a separate type registry (analogous to SystemGraphQLSchemaBuilder for GraphQL). New mutations must be registered in two methods:

    • buildMutationInterface() — add typeRefTo(SetSchemaTypeNewFieldMutationDescriptor.THIS.name())
    • buildOutputMutations() — add SetSchemaTypeNewFieldMutationDescriptor.THIS

    Note: SystemRestBuilder does NOT have a buildInputMutations() — the system API only reads mutations (from CDC/WAL), it does not accept mutation input.

  4. JSON serialization — for scope-aware fields, add a serialization method in SchemaJsonSerializer that converts Map<Scope, ...> to a JSON array. Call it from EntitySchemaJsonSerializer. For simple fields, Jackson serializes them from the getter automatically.

  5. REST functional test DTO helpers — update CatalogRestSchemaEndpointFunctionalTest.createReferenceSchemaDto() (or equivalent create*SchemaDto() method) to include the new field. For scope-aware fields, create a new create*Dto() helper method that converts the schema's map data into the expected JSON structure (list of maps with scope + value). Without this, all REST schema endpoint functional tests will fail with JSON path mismatches.

Backward compatibility note: New JSON fields in responses are non-breaking. Ensure request deserialization treats the new field as optional.

Checklist

  • gRPC: Proto definitions added with correct field numbers (never reused)
  • gRPC: Java stubs regenerated
  • gRPC: EvitaEnumConverter updated (if new enum)
  • gRPC: EntitySchemaConverter and mutation converters updated
  • gRPC: Empty/default handling is backward compatible
  • GraphQL: Types registered in CommonEvitaSchemaSchemaBuilder (Catalog API)
  • GraphQL: Types registered in SystemGraphQLSchemaBuilder (System API)
  • GraphQL: DataFetcher created and registered (if scope-aware)
  • GraphQL: Input types allow omission of new field
  • REST: Schema types registered in EntitySchemaObjectBuilder
  • REST: (New mutations only) Mutation registered in CatalogRestBuilder (3 methods: buildMutationInterface, buildInputMutations, buildOutputMutations)
  • REST: (New mutations only) Mutation registered in SystemRestBuilder (2 methods: buildMutationInterface, buildOutputMutations)
  • REST: Serializer added/updated
  • REST: Request deserialization treats new field as optional

Layer 7: Kryo Schema Serializers

Module: evita_store_server Package: io.evitadb.store.schema.serializer

7.1 Register New Enum (if applicable)

In SchemaKryoConfigurer.java — add at the end (before the assertion):

kryo.register(NewEnum.class, new EnumNameSerializer<>(), index++);

Always use EnumNameSerializer for enums — it persists names (not ordinals), safe across reordering. Never insert in the middle — append to preserve stable index numbering.

7.2 Serial Version Hash Workflow

The SerialVersionBasedSerializer uses the target class's serialVersionUID (explicitly declared via @Serial private static final long serialVersionUID = ...L;) to detect format changes. Every serialized object is prefixed with this UID; on deserialization, the UID is read first to dispatch to the correct serializer version.

Important — release-only backward compatibility: We only maintain backward compatibility with the latest released version (the latest release_YYYY-M branch). If a model changes multiple times during a single development cycle (between releases), do NOT create intermediate backward-compatible serializers — just update the current serializer. Only the release format matters.

Step-by-step process:

  1. Determine the latest release branch and version suffix:

    git branch -r --list 'origin/release_*' --sort=-v:refname | head -1
    

    This gives e.g. origin/release_2026-1 → suffix _2026_1, annotation @Deprecated(since = "2026.1").

  2. Compare serialVersionUID of the target class between the release branch and the current branch:

    # Release branch UID:
    git show origin/release_YYYY-M:path/to/SchemaType.java | grep serialVersionUID
    # Current branch UID:
    grep serialVersionUID path/to/SchemaType.java
    
  3. Decision tree:

    • UIDs identical → The target class hasn't changed since the release. No backward-compat serializer needed. Just update the current serializer to write/read the new field.

    • UIDs differ AND SchemaTypeSerializer_YYYY_M.java already exists → A backward-compat serializer for the release format was already created earlier in this dev cycle (from a previous change to this model). Just update the current serializer. No new backward-compat file needed.

    • UIDs differ AND no SchemaTypeSerializer_YYYY_M.java exists → This is the first change to this model since the release. Full workflow:

      a. Get the release-version serializer from the release branch:

      git show origin/release_YYYY-M:path/to/SchemaTypeSerializer.java
      

      b. Save it as SchemaTypeSerializer_YYYY_M.java. This becomes the backward-compatible reader for the release format. c. Annotate with @Deprecated(since = "YYYY.M", forRemoval = true). d. In the copy, make write() throw UnsupportedOperationException and keep read() unchanged — it reads the release format. e. Modify the original serializer to write/read the new field. f. Run the tests. SerialVersionBasedSerializer will detect the UID mismatch and report the old hash in the error message. Capture this value. g. Register the backward-compatible serializer with the captured hash:

      kryo.register(
          SchemaType.class,
          new SerialVersionBasedSerializer<>(
              new SchemaTypeSerializer(), SchemaType.class  // current version
          )
              .addBackwardCompatibleSerializer(EXISTING_HASH_1, new SchemaTypeSerializer_OLD_1())
              .addBackwardCompatibleSerializer(CAPTURED_OLD_HASH, new SchemaTypeSerializer_YYYY_M()),  // NEW
          index++
      );
      

7.3 Update Current Serializer

Add the new field to write/read in the current serializer. Follow existing patterns in the file:

// Simple boolean:
output.writeBoolean(schema.isNewField());
// Read:
final boolean newField = input.readBoolean();

// Enum via Kryo:
kryo.writeObject(output, schema.getNewField());
// Read:
final NewEnum newField = kryo.readObject(input, NewEnum.class);

// Nullable field — boolean flag pattern:
if (value != null) {
    output.writeBoolean(true);
    /* write value */
} else {
    output.writeBoolean(false);
}
// Read:
final T value = input.readBoolean() ? /* read value */ : null;

For ReflectedReferenceSchema (ReferenceSchema changes only), inherited fields use the boolean-flag pattern — false means inherited, true means explicitly set followed by the value.

7.4 Backward-Compatible Serializer

The backward-compat serializer reads the release format (without the new field). It must:

  • Read fields in exactly the order the release version wrote them
  • NOT read the new field (it doesn't exist in release data)
  • Pass a default or null to the _internalBuild() method for the new field
@Deprecated(since = "YYYY.M", forRemoval = true)
public class SchemaTypeSerializer_YYYY_M extends Serializer<SchemaType> {
    @Override
    public void write(Kryo kryo, Output output, SchemaType schema) {
        throw new UnsupportedOperationException(
            "This serializer is deprecated and should not be used for writing."
        );
    }

    @Override
    public SchemaType read(Kryo kryo, Input input, Class<? extends SchemaType> aClass) {
        // Read release format fields in original order...
        // Do NOT read the new field
        return SchemaType._internalBuild(/* old params, default for new field */);
    }
}

Checklist

  • EnumNameSerializer registered for new enum (if applicable)
  • Release branch identified, serialVersionUID compared (see decision tree above)
  • If first change since release: backward-compat serializer created from release-branch source, annotated @Deprecated, write throws
  • If subsequent change (backward-compat already exists): just update the current serializer — no new backward-compat file
  • Current serializer updated with new field read/write
  • Tests run to capture old serial version hash (first change only)
  • Backward-compatible serializer registered with captured hash in SchemaKryoConfigurer (first change only)
  • (ReferenceSchema only) Both ReferenceSchemaSerializer and ReflectedReferenceSchemaSerializer updated

Layer 8: WAL Mutation Serializers

Module: evita_store_server Package: io.evitadb.store.wal.schema.*

8.1 Register New Enum (if applicable)

In WalKryoConfigurer.java — add at the end (before the assertion):

kryo.register(NewEnum.class, new EnumNameSerializer<>(), index++);

8.2 Serial Version Hash Workflow

Follow the same decision tree as Layer 7.2 — compare the mutation class's serialVersionUID between the latest release branch and the current branch:

  1. UIDs identical → No backward-compat needed, just update the current serializer.
  2. UIDs differ AND MutationSerializer_YYYY_M.java already exists → Backward compat for the release format is already handled. Just update the current serializer.
  3. UIDs differ AND no backward-compat serializer exists → First change since release. Full workflow: a. Get the release-version serializer from the release branch b. Save as MutationSerializer_YYYY_M.java, annotate @Deprecated(since = "YYYY.M", forRemoval = true), make write throw UnsupportedOperationException c. Modify original to write/read new field d. Run tests, capture old hash from error message e. Register backward-compatible serializer with old hash in WalKryoConfigurer

8.3 Update Current Mutation Serializers

Use the boolean-flag + conditional pattern for nullable fields:

// Write:
if (mutation.getNewFieldInScopes() != null) {
    output.writeBoolean(true);
    /* write the field data */
} else {
    output.writeBoolean(false);
}

// Read:
final ScopedNewField[] newFieldInScopes =
    input.readBoolean() ? /* read the field data */ : null;

For simple non-nullable fields (e.g., boolean), just write/read directly without the flag.

Use existing helper methods from MutationSerializationFunctions interface (e.g., writeScopeArray, readScopeArray, writeScopedReferenceIndexTypeArray) as reference. Add new helpers to the interface if needed by multiple serializers.

8.4 Backward-Compatible Mutation Serializers

The backward-compatible serializer reads the old format without the new field and passes null or a default to the mutation constructor. Each affected mutation needs its own backward-compatible serializer.

Checklist

  • EnumNameSerializer registered for new enum in WalKryoConfigurer (if applicable)
  • Release branch identified, mutation class serialVersionUID compared (see decision tree above)
  • If first change since release: backward-compat serializer(s) created from release-branch source and deprecated
  • If subsequent change (backward-compat already exists): just update the current serializer — no new backward-compat file
  • Current mutation serializers updated with new field
  • Tests run to capture old hash(es) (first change only)
  • SerialVersionBasedSerializer chain updated in WalKryoConfigurer for each mutation (first change only)

Parallelization Guidance

Dependency Diagram

Layers 1 → 2 → 3 → 4  (strictly sequential)
                  ↓
       ┌──────────┼──────────┐
       ↓          ↓          ↓
   Layer 5     Layer 7    Layer 8
       ↓       (Kryo)     (WAL)
  ┌────┼────┐
  ↓    ↓    ↓
 6a   6b   6c
gRPC  GQL  REST

Safe Parallel Dispatch After Layer 4

Group Layers Description
A 5 -> 6a + 6b + 6c External API core, then all three APIs in parallel
B 7 Kryo schema serializers
C 8 WAL mutation serializers

Groups A, B, and C can run concurrently. Within Group A, the three external APIs (gRPC, GraphQL, REST) are independent after Layer 5 completes.

Testing should happen after all groups complete, since tests span all layers.


Testing

Module: evita_test/evita_functional_tests

Test Categories

Category Location What to Test
Schema builder schema/SchemaTypeBuilderTest.java Fluent API, mutation generation
DTO schema/dto/SchemaTypeDTOTest.java Construction, getters, equality
Create mutation schema/mutation/.../CreateMutationTest.java mutate(), combineWith()
Set mutation schema/mutation/.../SetMutationTest.java Apply, combine, change detection
Scoped record schema/mutation/.../ScopedNewFieldTest.java Validation, equality, hashCode
Core converter schemaApi/resolver/mutation/.../ConverterTest.java Round-trip conversion
gRPC converter grpc/requestResponse/schema/... gRPC <-> domain conversion
gRPC assertions grpc/testUtils/GrpcAssertions.java Update assertion helpers
GraphQL graphql/api/catalog/schemaApi/... Query functional tests
REST rest/api/catalog/schemaApi/... Endpoint functional tests
REST DTO helpers rest/.../CatalogRestSchemaEndpointFunctionalTest.java create*SchemaDto() helpers

Test Checklist

  • DTO: construction with explicit values and with defaults, getter behavior
  • Builder: fluent API produces correct mutations, null defaults work
  • Create mutation: mutate() builds correct schema, combineWith() generates diff mutations
  • Set mutation: applies correctly, combines correctly
  • Core converter: round-trip from JSON-like input to mutation and back
  • gRPC: converter round-trip, enum conversion both directions (if applicable)
  • GraphQL: query returns correct structure for new field
  • REST: JSON output includes new field in correct format
  • REST: create*SchemaDto() test helpers updated in CatalogRestSchemaEndpointFunctionalTest
  • (Scope-aware only) Scoped record: null-argument validation, equality with array fields
  • (ReferenceSchema only) Reflected: inheritance flag toggling, change detection edge cases

Quick Reference — File Patterns

Layer File Pattern
1 - Contract evita_api/.../schema/SchemaTypeContract.java
1 - Editor evita_api/.../schema/SchemaTypeEditor.java
2 - DTO evita_api/.../schema/dto/SchemaType.java
3 - Builder evita_api/.../schema/builder/SchemaTypeBuilder.java
4 - Mutations evita_api/.../schema/mutation/.../*Mutation.java
5 - Descriptors evita_external_api_core/.../schemaApi/model/*Descriptor.java
5 - Converters evita_external_api_core/.../schemaApi/resolver/mutation/.../*Converter.java
6a - Proto evita_external_api_grpc/.../resources/META-INF/.../Grpc*.proto
6a - Enum conv evita_external_api_grpc/.../requestResponse/EvitaEnumConverter.java
6a - Schema conv evita_external_api_grpc/.../requestResponse/schema/EntitySchemaConverter.java
6b - GQL catalog builder evita_external_api_graphql/.../schemaApi/builder/*SchemaBuilder.java
6b - GQL system builder evita_external_api_graphql/.../system/builder/SystemGraphQLSchemaBuilder.java
6b - DataFetcher evita_external_api_graphql/.../schemaApi/resolver/dataFetcher/*DataFetcher.java
6c - REST schema builder evita_external_api_rest/.../schemaApi/builder/*ObjectBuilder.java
6c - REST catalog builder evita_external_api_rest/.../catalog/CatalogRestBuilder.java
6c - REST system builder evita_external_api_rest/.../system/SystemRestBuilder.java
6c - REST serial evita_external_api_rest/.../schemaApi/resolver/serializer/*Serializer.java
7 - Schema serial evita_store_server/.../schema/serializer/SchemaTypeSerializer.java
7 - Schema compat evita_store_server/.../schema/serializer/SchemaTypeSerializer_YYYY_M.java
7 - Schema config evita_store_server/.../schema/SchemaKryoConfigurer.java
8 - WAL serial evita_store_server/.../wal/schema/.../*MutationSerializer.java
8 - WAL compat evita_store_server/.../wal/schema/.../*MutationSerializer_YYYY_M.java
8 - WAL config evita_store_server/.../wal/WalKryoConfigurer.java

Common Pitfalls

  1. Array equality in records. Java records use Object.equals() for array fields, which is reference equality. Always override equals(), hashCode(), and toString() in records that contain arrays.

  2. Null semantics for reflected reference schemas. null means "inherited from the target reference." Explicit empty array means "explicitly set to empty." These are semantically different. This only applies to ReflectedReferenceSchema.

  3. EnumMap initialization. Always use new EnumMap<>(Scope.class) or EnumSet.noneOf(NewEnum.class) — never raw HashMap/HashSet for enum keys.

  4. Unmodifiable wrappers. DTO fields must be wrapped with CollectionUtils.toUnmodifiableMap() / CollectionUtils.toUnmodifiableSet(). Missing this causes mutation bugs.

  5. _internalBuild() overload mismatch. There are typically two forms: array-based (for mutations/serializers) and map-based (for internal construction). Ensure both are updated and parameter order is consistent.

    CRITICAL: Also update all callers. Adding a parameter to _internalBuild() is not enough — many mutations reconstruct the schema (e.g., attribute mutations like CreateAttributeSchemaMutation, RemoveAttributeSchemaMutation, SetAttributeSchemaFilterableMutation, and sortable compound mutations). These call _internalBuild() with all fields from the existing referenceSchema to rebuild it. If they still call the OLD overload (missing the new parameter), the new field is silently dropped whenever the mutation is applied. Grep for all SchemaType._internalBuild( calls across the entire evita_api module and verify each passes the new parameter.

  6. Boolean flag for nullable serialization. In Kryo serializers, always write a boolean flag before nullable/optional fields. Read must match: input.readBoolean() ? readValue() : null. Forgetting the flag causes deserialization offset errors.

  7. Backward-compat serializer read order. The old serializer must read fields in exactly the order the old version wrote them. Do not read the new field — it doesn't exist in old data.

  8. Serial version hash capture. After modifying a Kryo serializer, the old hash must be captured by running tests — SerialVersionBasedSerializer reports the mismatch. Register the old hash with the backward-compatible serializer. Forgetting this step causes deserialization failures on stored data.

  9. gRPC proto field numbers are permanent. Never reuse a field number. Always use the next available number. Old clients ignore unknown field numbers, so additions are safe.

  10. gRPC empty-list vs null. In proto3, unset repeated fields are empty lists. Converters must treat empty list as null (not provided) to distinguish "not set" from "explicitly empty."

  11. External API backward compatibility. All three web APIs (gRPC, GraphQL, REST) must handle requests that omit the new field. Converters must supply a sensible default or null for the missing field. Test with payloads that don't include the new field.

  12. _internalBuild() callers silently drop new fields. When you add a parameter to _internalBuild(), every existing CALLER that reconstructs the schema via _internalBuild() will still compile against the OLD overload (which lacks the new parameter). The new field is silently dropped — no compiler error, no runtime error, just missing data. This is especially dangerous for ReferenceSchema which is rebuilt by ~12 attribute and sortable-compound mutations (e.g., CreateAttributeSchemaMutation.mutate(), RemoveAttributeSchemaMutation.mutate(), ReferenceAttributeSchemaMutation, etc.). Always grep for SchemaType._internalBuild( across the entire evita_api module and verify every call site passes the new parameter. A regression test that calls .withAttribute(...).bucketed(...) (or equivalent) will catch this — the attribute mutation's _internalBuild() call will drop the new field if not updated.

  13. Enum registration order in Kryo configurers. New registrations must be appended at the end (before the assertion) to maintain stable index numbering. Never insert in the middle — it shifts all subsequent indices and breaks deserialization.

  14. Missing gRPC UNRECOGNIZED case. The gRPC enum converter toEvita() method must handle UNRECOGNIZED by throwing EvitaInvalidUsageException. The toGrpc() direction does not need it.

  15. Output.toSerializableValue() doesn't support custom types. The reflection-based convertObjectToOutput() in MutationConverter will throw for any @SerializableCreator parameter type not handled by toSerializableValue() (enums, primitives, strings, arrays of primitives are supported; custom domain objects like Expression are NOT). You must override convertToOutput() to pre-serialize these fields. This is easy to miss because the compile succeeds — the error only surfaces at runtime during test execution.

  16. REST functional test DTO helpers must match serializer output. When adding a field to SchemaJsonSerializer, the corresponding create*SchemaDto() test helper in CatalogRestSchemaEndpointFunctionalTest (or the relevant endpoint test base class) must also be updated. Missing this causes all REST schema endpoint functional tests to fail with JSON path mismatches — often 10+ failures that look like unrelated issues.

  17. SystemGraphQLSchemaBuilder is a separate type registry. evitaDB has two independent GraphQL schema builders: CommonEvitaSchemaSchemaBuilder (catalog API) and SystemGraphQLSchemaBuilder (system API). They maintain separate type registries. Registering a new type in one does NOT make it available in the other. Missing the system builder causes 500+ graphql.AssertException: type ... not found in schema errors across all system GraphQL tests and EvitaServerTest. Look for existing Scoped*Descriptor.THIS registrations in SystemGraphQLSchemaBuilder.build() and add the new one alongside them.

  18. Backward-compat serializer version suffix and release-only policy. The YYYY_M suffix on backward-compatible serializer files must match the last released version (latest release_YYYY-M branch), not the current development version. Determine it via git branch -r --list 'origin/release_*' --sort=-v:refname | head -1. For example, origin/release_2026-1 → suffix _2026_1, annotation @Deprecated(since = "2026.1"). Crucially, we only maintain backward compatibility with the release version. If a model changes multiple times during a dev cycle and a backward-compat serializer for the release already exists, do NOT create additional backward-compat serializers for intermediate states — just update the current serializer.

  19. CatalogRestBuilder and SystemRestBuilder are separate mutation registries. evitaDB has two independent REST API builders: CatalogRestBuilder (catalog API) and SystemRestBuilder (system API). When adding a new mutation, it must be registered in both. CatalogRestBuilder has 3 methods (buildMutationInterface, buildInputMutations, buildOutputMutations); SystemRestBuilder has 2 (buildMutationInterface, buildOutputMutations — no input mutations). Missing either builder causes OpenApiBuildingError: Found missing schema in OpenAPI for the mutation name and its input variant. This is the REST analog of pitfall #16 (GraphQL dual builders). Look for existing Set*MutationDescriptor registrations near SetReferenceSchemaFacetedMutationDescriptor and add the new one alongside them.

  20. input.getOptionalProperty() raw vs typed overload. getOptionalProperty(String) returns the raw underlying object (e.g., List) without type conversion. getOptionalProperty(String, Class) invokes toTargetType() which handles List -> array conversion. Using the wrong overload causes ClassCastException at runtime when the mutation constructor expects an array type.


Implementation Order

  1. Layers 1-4 (strictly sequential): Contracts -> DTOs -> Builders -> Mutations
  2. Build verification: Compile evita_api module to verify no API breaks
  3. Layers 5, 7, 8 (in parallel):
    • Group A: Layer 5 (Core API descriptors/converters), then Layer 6a/6b/6c in parallel
    • Group B: Layer 7 (Kryo schema serializers + backward compat)
    • Group C: Layer 8 (WAL mutation serializers + backward compat)
  4. Testing (after all groups complete): Write/update tests across all layers
  5. Build verification: Full mvn clean install with unitAndFunctional profile

Final Step: Code Quality Review

After all layers are implemented and tests pass, offer the user to run the /code-quality-pipeline skill on all changed files. This runs the test-architect, code-simplifier, and bug-hunter-tdd agents to review test coverage, code clarity, and potential bugs across the full set of changes.

Install via CLI
npx skills add https://github.com/FgForrest/evitaDB --skill evita-schema-change
Repository Details
star Stars 67
call_split Forks 7
navigation Branch main
article Path SKILL.md
More from Creator