Back to Blog

Source Code Reading: SchemaRegistryService — Ontology Registration State Machine

SchemaRegistryService is the core service in the Control Layer (Control Layer) managing the full lifecycle of ontology schemas. Built on Spring Boot 3.x + gRPC, it implements a four-state machine (DRAFT -> ACTIVE -> DEPRECATED -> ARCHIVED) with built-in forward/backward compatibility checking, dependency analysis, version history management, and bulk import/export. This article analyzes the state machine transition rules, CompatibilityChecker's three-level compatibility strategy, SchemaDependencyChecker's cascading impact analysis, and SchemaVersionHistory's immutable version chain design.

CoomiaPublished on December 1, 202511 min read
Share this articleTwitter / X

Source Code Reading: SchemaRegistryService — Ontology Registration State Machine

Series: S9 Source Code Reading · Article 2 | Level: Advanced | Reading Time: 25 min

#TL;DR

SchemaRegistryService is the core service in the Control Layer (Control Layer) managing the full lifecycle of ontology schemas. Built on Spring Boot 3.x + gRPC, it implements a four-state machine (DRAFT -> ACTIVE -> DEPRECATED -> ARCHIVED) with built-in forward/backward compatibility checking, dependency analysis, version history management, and bulk import/export. This article analyzes the state machine transition rules, CompatibilityChecker's three-level compatibility strategy, SchemaDependencyChecker's cascading impact analysis, and SchemaVersionHistory's immutable version chain design.

#Table of Contents

  1. Architecture Position and Module Structure
  2. Domain Model: SchemaEntity and SchemaStatus
  3. gRPC Service Entry: SchemaRegistryServiceImpl
  4. State Machine: Four-State Transitions and Guard Conditions
  5. Compatibility Checking: Three-Level Strategy
  6. Dependency Analysis: SchemaDependencyChecker
  7. Version Management: SchemaVersionHistory
  8. Complete Schema CRUD Flow
  9. Bulk Import/Export: YAML/JSON Dual Format
  10. EventRef Validation and Cross-Entity References
  11. Key Takeaways

#1. Architecture Position and Module Structure

Schema Registry sits in the Control Layer as the platform's "metadata brain." All Layers must query the Schema Registry for entity type definitions before operating on data.

Code
control-Layer/src/main/java/com/onto/control/schema/
├── api/
│   ├── SchemaRegistryServiceImpl.java    # gRPC service implementation
│   ├── ActionTypeGrpcService.java        # ActionType registration
│   └── StructTypeGrpcService.java        # StructType registration
├── domain/
│   ├── SchemaEntity.java                 # Schema aggregate root
│   ├── SchemaStatus.java                 # Status enum
│   ├── SchemaLifecycleEvent.java         # Lifecycle events
│   └── SchemaVersionHistory.java         # Version history
├── service/
│   ├── SchemaService.java                # Business service
│   ├── CompatibilityChecker.java         # Compatibility checker
│   ├── SchemaDependencyChecker.java      # Dependency checker
│   └── EventRefValidator.java            # Event reference validator
├── mapper/
│   └── SchemaMapper.java                 # Proto <-> Domain mapping
├── analyzer/
│   ├── DependencyAnalyzer.java           # Dependency graph analysis
│   ├── DependencyGraph.java              # Graph data structure
│   ├── ImpactAnalysis.java               # Impact assessment
│   └── DependencyCacheService.java       # Dependency cache
└── repository/
    └── SchemaRepository.java             # JPA persistence layer

Palantir Foundry comparison: Foundry's Ontology Manager uses REST APIs to manage ObjectType, LinkType, and ActionType. coomia-dip's SchemaRegistryService mirrors this functionality but adds state machine, compatibility checking, and dependency analysis — features that require manual management in Foundry.

#2. Domain Model: SchemaEntity and SchemaStatus

Java
@Entity
@Table(name = "schema_registry")
public class SchemaEntity {
    @Id
    private String schemaId;
    private String schemaName;
    private String entityType;         // OBJECT_TYPE, LINK_TYPE, INTERFACE
    private int version;
    private String definition;          // JSON/YAML schema definition
    private String worldId;

    @Enumerated(EnumType.STRING)
    private SchemaStatus status;

    private String createdBy;
    private LocalDateTime createdAt;
    private String updatedBy;
    private LocalDateTime updatedAt;

    @Enumerated(EnumType.STRING)
    private CompatibilityMode compatibilityMode;  // NONE, BACKWARD, FORWARD, FULL

    @Column(columnDefinition = "TEXT")
    private String versionHistory;
}
Java
public enum SchemaStatus {
    DRAFT,        // Draft, freely editable
    ACTIVE,       // Activated, changes require compatibility checks
    DEPRECATED,   // Deprecated, read-only
    ARCHIVED      // Archived, invisible
}

State transition diagram:

Code
DRAFT --activate()--> ACTIVE --deprecate()--> DEPRECATED --archive()--> ARCHIVED
  ^                     |                        |
  |                     | revert()               | revert()
  +---------------------+                        |
  ^                                              |
  +----------------------------------------------+

Design rationale: The DRAFT state allows free modification without triggering compatibility checks. Only the DRAFT -> ACTIVE activate() transition executes the full validation chain. This makes iterative Schema development more efficient — fields can be adjusted repeatedly during the DRAFT phase.

#3. gRPC Service Entry: SchemaRegistryServiceImpl

Java
@GrpcService
@Slf4j
@RequiredArgsConstructor
public class SchemaRegistryServiceImpl
        extends SchemaRegistryServiceGrpc.SchemaRegistryServiceImplBase {

    private final SchemaService schemaService;
    private final CompatibilityChecker compatibilityChecker;
    private final SchemaDependencyChecker dependencyChecker;
    private final EventRefValidator eventRefValidator;
    private final SchemaMapper mapper;

    @Override
    public void registerSchema(RegisterSchemaRequest request,
            StreamObserver<SchemaResponse> responseObserver) {
        try {
            String worldId = WorldContextHolder.getWorldId();
            String userId = WorldContextHolder.getUserId();

            log.info("RegisterSchema: name={}, world={}",
                request.getSchema().getSchemaName(), worldId);

            // 1. Convert Proto -> Domain
            SchemaEntity entity = mapper.toDomain(
                request.getSchema(), worldId, userId);

            // 2. Set initial state
            entity.setStatus(SchemaStatus.DRAFT);
            entity.setVersion(1);

            // 3. EventRef validation (if referencing event types)
            if (request.getSchema().hasEventRef()) {
                eventRefValidator.validate(
                    request.getSchema().getEventRef(), worldId);
            }

            // 4. Persist
            SchemaEntity saved = schemaService.register(entity);

            // 5. Respond
            responseObserver.onNext(mapper.toResponse(saved));
            responseObserver.onCompleted();
        } catch (Exception e) {
            handleException(e, responseObserver);
        }
    }
}

Note: Newly registered schemas always start in DRAFT state with version 1. This ensures no schema can bypass review and enter production directly.

#4. State Machine: Four-State Transitions and Guard Conditions

Java
@Override
public void activateSchema(ActivateSchemaRequest request,
        StreamObserver<SchemaResponse> responseObserver) {
    try {
        String worldId = WorldContextHolder.getWorldId();
        SchemaEntity entity = schemaService.getById(
            request.getSchemaId(), worldId);

        // Guard 1: Only DRAFT can be activated
        if (entity.getStatus() != SchemaStatus.DRAFT) {
            throw Status.FAILED_PRECONDITION
                .withDescription(String.format(
                    "Schema '%s' is in %s state, only DRAFT can be activated",
                    entity.getSchemaName(), entity.getStatus()))
                .asRuntimeException();
        }

        // Guard 2: Compatibility check (if previous ACTIVE version exists)
        Optional<SchemaEntity> previousActive =
            schemaService.findActiveByName(
                entity.getSchemaName(), worldId);
        if (previousActive.isPresent()) {
            CompatibilityResult result =
                compatibilityChecker.check(
                    previousActive.get(), entity);
            if (!result.isCompatible()) {
                throw Status.FAILED_PRECONDITION
                    .withDescription(
                        "Compatibility check failed: "
                        + result.getViolations())
                    .asRuntimeException();
            }
            // Auto-deprecate old version
            schemaService.deprecate(previousActive.get());
        }

        // Guard 3: Dependency integrity
        dependencyChecker.validateDependencies(entity, worldId);

        // State transition
        entity.setStatus(SchemaStatus.ACTIVE);
        SchemaEntity activated = schemaService.update(entity);

        // Record lifecycle event
        schemaService.recordLifecycleEvent(
            entity.getSchemaId(),
            SchemaLifecycleEvent.ACTIVATED,
            WorldContextHolder.getUserId());

        responseObserver.onNext(mapper.toResponse(activated));
        responseObserver.onCompleted();
    } catch (Exception e) {
        handleException(e, responseObserver);
    }
}

Triple guard conditions:

  1. State check: Only DRAFT status can be activated
  2. Compatibility check: If an ACTIVE version of the same schema name exists, the new version must pass compatibility validation
  3. Dependency integrity: Referenced schemas (e.g., ObjectTypes referenced by a LinkType) must exist and be ACTIVE

Auto-deprecation of old version: Activating a new version automatically moves the old ACTIVE version to DEPRECATED, ensuring only one ACTIVE version exists at any time to prevent ambiguity.

#5. Compatibility Checking: Three-Level Strategy

Java
@Component
public class CompatibilityChecker {

    public CompatibilityResult check(
            SchemaEntity existing, SchemaEntity proposed) {
        CompatibilityMode mode = existing.getCompatibilityMode();
        List<String> violations = new ArrayList<>();

        switch (mode) {
            case BACKWARD:
                checkBackwardCompatibility(existing, proposed, violations);
                break;
            case FORWARD:
                checkForwardCompatibility(existing, proposed, violations);
                break;
            case FULL:
                checkBackwardCompatibility(existing, proposed, violations);
                checkForwardCompatibility(existing, proposed, violations);
                break;
            case NONE:
                break;
        }

        return new CompatibilityResult(violations.isEmpty(), violations);
    }

    private void checkBackwardCompatibility(
            SchemaEntity existing, SchemaEntity proposed,
            List<String> violations) {
        Map<String, PropertyDef> existingProps = parseProperties(existing);
        Map<String, PropertyDef> proposedProps = parseProperties(proposed);

        // Rule 1: Cannot remove existing required fields
        for (Map.Entry<String, PropertyDef> entry :
                existingProps.entrySet()) {
            if (entry.getValue().isRequired()
                    && !proposedProps.containsKey(entry.getKey())) {
                violations.add(String.format(
                    "Cannot remove required property '%s' "
                    + "(backward incompatible)", entry.getKey()));
            }
        }

        // Rule 2: Cannot narrow field type range
        for (Map.Entry<String, PropertyDef> entry :
                proposedProps.entrySet()) {
            PropertyDef existingProp = existingProps.get(entry.getKey());
            if (existingProp != null
                    && !isTypeWidening(
                        existingProp.getType(),
                        entry.getValue().getType())) {
                violations.add(String.format(
                    "Cannot narrow type of property '%s' from %s to %s",
                    entry.getKey(), existingProp.getType(),
                    entry.getValue().getType()));
            }
        }

        // Rule 3: New required fields must have default values
        for (Map.Entry<String, PropertyDef> entry :
                proposedProps.entrySet()) {
            if (!existingProps.containsKey(entry.getKey())
                    && entry.getValue().isRequired()
                    && !entry.getValue().hasDefaultValue()) {
                violations.add(String.format(
                    "New required property '%s' must have a default value",
                    entry.getKey()));
            }
        }
    }

    private boolean isTypeWidening(String from, String to) {
        Map<String, Integer> typeWidth = Map.of(
            "boolean", 1,
            "int", 2, "integer", 2,
            "long", 3,
            "float", 4,
            "double", 5,
            "string", 10  // string can accommodate any type
        );
        return typeWidth.getOrDefault(to.toLowerCase(), 0)
            >= typeWidth.getOrDefault(from.toLowerCase(), 0);
    }
}

Three-level compatibility strategies:

ModeMeaningUse Case
BACKWARDNew consumers can read old producer dataMost scenarios
FORWARDOld consumers can read new producer dataGradual deployment
FULLBidirectional compatibilityStrict environments
NONENo checkingDevelopment phase

This design borrows from Apache Avro / Confluent Schema Registry compatibility models, adapted for ontology property semantics.

#6. Dependency Analysis: SchemaDependencyChecker

Java
@Component
public class SchemaDependencyChecker {

    private final DependencyAnalyzer analyzer;
    private final DependencyCacheService cacheService;

    public void validateDependencies(SchemaEntity schema, String worldId) {
        DependencyGraph graph = cacheService.getOrBuild(worldId, () ->
            analyzer.buildGraph(worldId));

        List<SchemaEdge> edges = graph.getOutgoingEdges(schema.getSchemaId());
        for (SchemaEdge edge : edges) {
            SchemaNode target = graph.getNode(edge.getTargetId());
            if (target == null) {
                throw new DependencyMissingException(
                    schema.getSchemaName(), edge.getTargetId());
            }
            if (target.getStatus() != SchemaStatus.ACTIVE) {
                throw new DependencyNotActiveException(
                    schema.getSchemaName(),
                    target.getSchemaName(),
                    target.getStatus());
            }
        }
    }

    public ImpactAnalysis analyzeImpact(String schemaId, String worldId) {
        DependencyGraph graph = cacheService.getOrBuild(worldId, () ->
            analyzer.buildGraph(worldId));

        List<ImpactItem> impacted = new ArrayList<>();
        Queue<String> queue = new LinkedList<>();
        Set<String> visited = new HashSet<>();
        queue.add(schemaId);

        while (!queue.isEmpty()) {
            String current = queue.poll();
            if (visited.contains(current)) continue;
            visited.add(current);

            List<SchemaEdge> incoming = graph.getIncomingEdges(current);
            for (SchemaEdge edge : incoming) {
                ImpactLevel level = (edge.getDependencyType()
                    == DependencyType.HARD)
                    ? ImpactLevel.BREAKING
                    : ImpactLevel.WARNING;
                impacted.add(new ImpactItem(
                    edge.getSourceId(),
                    graph.getNode(edge.getSourceId()).getSchemaName(),
                    level));
                queue.add(edge.getSourceId());
            }
        }

        return new ImpactAnalysis(schemaId, impacted);
    }
}

The dependency graph is lazily built and cached per World. Each World has an isolated Schema space, so dependency graphs are also isolated. DependencyCacheService invalidates the cache when schemas change.

Impact Analysis uses BFS traversal of reverse edges in the dependency graph to identify all affected downstream schemas. This feature is invaluable before deprecating a schema — enabling teams to know in advance which downstream consumers will be impacted.

#7. Version Management: SchemaVersionHistory

Java
public class SchemaVersionHistory {
    private final List<VersionEntry> entries;

    @Value
    public static class VersionEntry {
        int version;
        String schemaId;
        SchemaStatus status;
        String changedBy;
        LocalDateTime changedAt;
        String changeDescription;
        String checksum;         // SHA-256 of definition
    }

    public void addVersion(SchemaEntity entity, String changeDescription) {
        String checksum = DigestUtils.sha256Hex(entity.getDefinition());

        if (!entries.isEmpty()) {
            VersionEntry latest = entries.get(entries.size() - 1);
            if (latest.getChecksum().equals(checksum)) {
                throw new DuplicateVersionException(
                    "Schema content unchanged, version bump not needed");
            }
        }

        entries.add(new VersionEntry(
            entity.getVersion(),
            entity.getSchemaId(),
            entity.getStatus(),
            entity.getUpdatedBy(),
            entity.getUpdatedAt(),
            changeDescription,
            checksum
        ));
    }
}

The version chain is immutable — only addVersion operations exist, with no delete or modify. Each version record includes a SHA-256 checksum for duplicate detection. If the definition content is identical, a new version is rejected to prevent version number inflation.

#8. Complete Schema CRUD Flow

#8.1 Update Schema

Java
@Override
public void updateSchema(UpdateSchemaRequest request,
        StreamObserver<SchemaResponse> responseObserver) {
    try {
        String worldId = WorldContextHolder.getWorldId();
        String userId = WorldContextHolder.getUserId();

        SchemaEntity existing = schemaService.getById(
            request.getSchemaId(), worldId);

        if (existing.getStatus() == SchemaStatus.ACTIVE) {
            // Create new DRAFT version instead of modifying directly
            SchemaEntity newVersion = mapper.toDomain(
                request.getSchema(), worldId, userId);
            newVersion.setVersion(existing.getVersion() + 1);
            newVersion.setStatus(SchemaStatus.DRAFT);
            SchemaEntity saved = schemaService.register(newVersion);
            responseObserver.onNext(mapper.toResponse(saved));
        } else if (existing.getStatus() == SchemaStatus.DRAFT) {
            // DRAFT can be modified directly
            mapper.updateEntity(existing, request.getSchema());
            existing.setUpdatedBy(userId);
            existing.setUpdatedAt(LocalDateTime.now());
            SchemaEntity updated = schemaService.update(existing);
            responseObserver.onNext(mapper.toResponse(updated));
        } else {
            throw Status.FAILED_PRECONDITION
                .withDescription("Cannot update schema in "
                    + existing.getStatus() + " state")
                .asRuntimeException();
        }

        responseObserver.onCompleted();
    } catch (Exception e) {
        handleException(e, responseObserver);
    }
}

Critical design: ACTIVE schemas cannot be directly modified — update operations create a new DRAFT version (version + 1). This ensures production stability: running services read the ACTIVE version, and new versions must go through the activate() flow (including compatibility checks) before replacing it.

#9. Bulk Import/Export: YAML/JSON Dual Format

Java
@Override
public void exportSchemas(ExportSchemasRequest request,
        StreamObserver<ExportSchemasResponse> responseObserver) {
    try {
        String worldId = WorldContextHolder.getWorldId();
        List<SchemaEntity> schemas = schemaService.listAll(worldId);

        String format = request.getFormat().isEmpty()
            ? "yaml" : request.getFormat();

        ObjectMapper mapper;
        if ("yaml".equalsIgnoreCase(format)) {
            mapper = new ObjectMapper(new YAMLFactory());
        } else {
            mapper = new ObjectMapper();
        }

        List<Map<String, Object>> exportData = schemas.stream()
            .map(this::toExportMap)
            .collect(Collectors.toList());

        byte[] content = mapper.writeValueAsBytes(exportData);

        responseObserver.onNext(
            ExportSchemasResponse.newBuilder()
                .setContent(ByteString.copyFrom(content))
                .setFormat(format)
                .setSchemaCount(schemas.size())
                .build());
        responseObserver.onCompleted();
    } catch (Exception e) {
        handleException(e, responseObserver);
    }
}

Dual format design: YAML for human-readable version control (can be committed to Git), JSON for programmatic transfer. Exports include the complete schema definition, status, version history, and compatibility mode.

#10. EventRef Validation and Cross-Entity References

Java
@Component
public class EventRefValidator {

    private final SchemaService schemaService;

    public void validate(EventRefProto eventRef, String worldId) {
        String objectTypeId = eventRef.getObjectTypeId();
        Optional<SchemaEntity> objectType =
            schemaService.findActiveBySchemaId(objectTypeId, worldId);

        if (objectType.isEmpty()) {
            throw Status.FAILED_PRECONDITION
                .withDescription(String.format(
                    "Referenced ObjectType '%s' not found or not ACTIVE",
                    objectTypeId))
                .asRuntimeException();
        }

        for (String propertyRef : eventRef.getPropertyRefsList()) {
            if (!hasProperty(objectType.get(), propertyRef)) {
                throw Status.INVALID_ARGUMENT
                    .withDescription(String.format(
                        "Property '%s' not found in ObjectType '%s'",
                        propertyRef, objectTypeId))
                    .asRuntimeException();
            }
        }
    }
}

EventRef is the core concept of coomia-dip's Object-Event model: each ObjectType can be associated with event sources, and event property references must point to properties defined in the ObjectType. EventRefValidator ensures referential integrity.

#11. Key Takeaways

  1. Four-state machine: DRAFT -> ACTIVE -> DEPRECATED -> ARCHIVED, each transition with explicit guard conditions
  2. ACTIVE immutability principle: Modifying an ACTIVE Schema creates a new DRAFT version, protecting production stability
  3. Three-level compatibility model: BACKWARD / FORWARD / FULL, borrowing Schema Registry best practices
  4. Dependency graph + impact analysis: Auto-evaluates downstream impact before deprecation, supports force override
  5. Immutable version chain: SHA-256 checksum prevents duplicates, append-only
  6. YAML/JSON dual-format export: Supports Git version control and programmatic transfer
  7. EventRef referential integrity: Cross-entity references validated at registration time, preventing runtime NPE

#Next Article

S9-03: WorldManagerService — Git Operations for Data Worlds. We will dive into the Nessie integration layer to see how coomia-dip uses Git semantics to manage data branching, merging, releases, and time travel.

Tags: #coomia-dip #source-code-reading #control-Layer #spring-boot #grpc #state-machine #schema-registry #compatibility-check #dependency-analysis