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.
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
- Architecture Position and Module Structure
- Domain Model: SchemaEntity and SchemaStatus
- gRPC Service Entry: SchemaRegistryServiceImpl
- State Machine: Four-State Transitions and Guard Conditions
- Compatibility Checking: Three-Level Strategy
- Dependency Analysis: SchemaDependencyChecker
- Version Management: SchemaVersionHistory
- Complete Schema CRUD Flow
- Bulk Import/Export: YAML/JSON Dual Format
- EventRef Validation and Cross-Entity References
- 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.
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
@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;
}
public enum SchemaStatus {
DRAFT, // Draft, freely editable
ACTIVE, // Activated, changes require compatibility checks
DEPRECATED, // Deprecated, read-only
ARCHIVED // Archived, invisible
}
State transition diagram:
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
@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
@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:
- State check: Only DRAFT status can be activated
- Compatibility check: If an ACTIVE version of the same schema name exists, the new version must pass compatibility validation
- 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
@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:
| Mode | Meaning | Use Case |
|---|---|---|
BACKWARD | New consumers can read old producer data | Most scenarios |
FORWARD | Old consumers can read new producer data | Gradual deployment |
FULL | Bidirectional compatibility | Strict environments |
NONE | No checking | Development phase |
This design borrows from Apache Avro / Confluent Schema Registry compatibility models, adapted for ontology property semantics.
#6. Dependency Analysis: SchemaDependencyChecker
@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
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
@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
@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
@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
- Four-state machine: DRAFT -> ACTIVE -> DEPRECATED -> ARCHIVED, each transition with explicit guard conditions
- ACTIVE immutability principle: Modifying an ACTIVE Schema creates a new DRAFT version, protecting production stability
- Three-level compatibility model: BACKWARD / FORWARD / FULL, borrowing Schema Registry best practices
- Dependency graph + impact analysis: Auto-evaluates downstream impact before deprecation, supports force override
- Immutable version chain: SHA-256 checksum prevents duplicates, append-only
- YAML/JSON dual-format export: Supports Git version control and programmatic transfer
- 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