| 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 {
VALUE_ONE,
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 isNewField();
@Nonnull
NewEnum getNewField();
@Nullable
String getNewField();
1.3 Add Editor Methods
Fluent methods returning the builder type T:
@Nonnull
default T withNewField(@Nonnull NewEnum... values) {
return withNewFieldInScope(Scope.DEFAULT_SCOPE, values);
}
@Nonnull
T withNewFieldInScope(@Nonnull Scope scope, @Nonnull NewEnum... values);
@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.
boolean isNewFieldInherited();
@Nonnull
S withNewFieldInherited();
Checklist
Layer 2: Schema DTOs
Module: evita_api
Package: io.evitadb.api.requestResponse.schema.dto
2.1 Add Field and Constructor Parameter
protected final Map<Scope, Set<NewEnum>> newFieldInScopes;
this.newFieldInScopes = CollectionUtils.toUnmodifiableMap(newFieldInScopes);
private final boolean newField;
2.2 Implement Contract Getters
@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:
@Nonnull
public static SchemaType _internalBuild(
// ... existing params ...
@Nullable ScopedNewField[] newFieldInScopes, // scope-aware
// OR: boolean newField, // simple
// ... remaining params ...
)
@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;
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
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(),
,
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(
,
null,
)
);
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
)
)
);
return this;
}
Checklist
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");
}
@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;
this(, null, );
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
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
PropertyDescriptor NEW_FIELD = PropertyDescriptor.builder()
.name("newField")
.description()
.type()
.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:
final Scope[] scopes = input.getOptionalProperty(FACETED_IN_SCOPES.name());
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
) {
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
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
- Type registration in Catalog API — register
ScopedNewFieldDescriptor.THIS and THIS_INPUT in CommonEvitaSchemaSchemaBuilder
- 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.
- 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.
- 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
-
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.
-
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
-
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.
-
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.
-
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
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:
-
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").
-
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
-
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
)
.addBackwardCompatibleSerializer(EXISTING_HASH_1, new SchemaTypeSerializer_OLD_1())
.addBackwardCompatibleSerializer(CAPTURED_OLD_HASH, new SchemaTypeSerializer_YYYY_M()),
index++
);
7.3 Update Current Serializer
Add the new field to write/read in the current serializer. Follow existing patterns in the file:
output.writeBoolean(schema.isNewField());
final boolean newField = input.readBoolean();
kryo.writeObject(output, schema.getNewField());
final NewEnum newField = kryo.readObject(input, NewEnum.class);
if (value != null) {
output.writeBoolean(true);
} else {
output.writeBoolean(false);
}
final T value = input.readBoolean() ? : 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) {
return SchemaType._internalBuild();
}
}
Checklist
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:
- UIDs identical → No backward-compat needed, just update the current serializer.
- UIDs differ AND
MutationSerializer_YYYY_M.java already exists → Backward compat for the release format is already handled. Just update the current serializer.
- 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:
if (mutation.getNewFieldInScopes() != null) {
output.writeBoolean(true);
} else {
output.writeBoolean(false);
}
final ScopedNewField[] newFieldInScopes =
input.readBoolean() ? : 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
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
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
-
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.
-
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.
-
EnumMap initialization. Always use new EnumMap<>(Scope.class) or EnumSet.noneOf(NewEnum.class) — never raw HashMap/HashSet for enum keys.
-
Unmodifiable wrappers. DTO fields must be wrapped with CollectionUtils.toUnmodifiableMap() / CollectionUtils.toUnmodifiableSet(). Missing this causes mutation bugs.
-
_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.
-
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.
-
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.
-
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.
-
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.
-
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."
-
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.
-
_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.
-
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.
-
Missing gRPC UNRECOGNIZED case. The gRPC enum converter toEvita() method must handle UNRECOGNIZED by throwing EvitaInvalidUsageException. The toGrpc() direction does not need it.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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
- Layers 1-4 (strictly sequential): Contracts -> DTOs -> Builders -> Mutations
- Build verification: Compile
evita_api module to verify no API breaks
- 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)
- Testing (after all groups complete): Write/update tests across all layers
- 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.