Implementing a Git-Driven GraphQL Schema Integrity Validator in C#


A production incident was traced back to a seemingly innocuous GraphQL schema change. A field was renamed in one service’s schema, which cascaded through our federated graph. The CI pipeline for that service remained green, as its own tests were updated to reflect the change. However, a downstream mobile client, relying on persisted queries, began failing catastrophically for a segment of users who had received the app update but were hitting the old backend version during rollout. The post-mortem identified a critical gap: schema validation was happening far too late in the lifecycle, and it wasn’t cross-referenced against the reality of our deployed graph.

The immediate goal was to shift this validation left, directly into the developer’s workflow. The existing ecosystem offered Node.js-based tools for this, but our backend development environment is standardized on .NET. Introducing a Node.js dependency solely for a pre-commit hook felt like an unnecessary complication and an extra maintenance burden. The decision was made to build a lightweight, native .NET tool to serve this purpose. The tool needed to operate as a Git hook, programmatically inspect staged schema files, and validate them against a live, production-like schema registry before allowing a commit to proceed.

This is not about simply linting a .graphql file. It’s about preventing structural incompatibilities by comparing a proposed schema change against a source of truth—in our case, the introspected schema of our staging gateway.

The Technical Foundation: Project Setup and Dependencies

The core of this tool is a .NET Console Application. Its effectiveness hinges on three key libraries:

  1. LibGit2Sharp: A robust .NET wrapper for the libgit2 library. It provides comprehensive, programmatic access to a Git repository’s internals without shelling out to the git command-line interface. This is crucial for reliability and performance.
  2. GraphQL.Client: A powerful and widely-used GraphQL client for .NET. We will use it to perform an introspection query against our running GraphQL gateway to fetch the current “golden” schema.
  3. GraphQL-Parser: A standalone, high-performance library for parsing GraphQL schemas into an Abstract Syntax Tree (AST). Simple string comparison of schemas is useless; we need to compare their semantic structure, and that begins with parsing.

The project file (.csproj) outlines these dependencies. In a real-world project, explicit versioning is non-negotiable to ensure build reproducibility.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="GraphQL.Client" Version="6.0.2" />
    <PackageReference Include="GraphQL.Client.Serializer.SystemTextJson" Version="6.0.2" />
    <PackageReference Include="GraphQL-Parser" Version="8.2.0" />
    <PackageReference Include="LibGit2Sharp" Version="0.29.0" />
    <PackageReference Include="Serilog" Version="3.1.1" />
    <PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
  </ItemGroup>

</Project>

We also include Serilog for structured logging. A tool like this, running in a developer’s terminal, must provide clear, actionable output, especially on failure.

Phase 1: Interacting with the Git Staging Area

The tool’s entry point must locate the Git repository and inspect the files the user intends to commit. Using LibGit2Sharp, we can directly access the repository’s index, which represents the staging area.

The first challenge is finding the root of the repository from where the script is executed. The hook will be run from the repository root, but for robustness, the code should be able to walk up the directory tree to find the .git directory.

// In Program.cs
using LibGit2Sharp;
using Serilog;

// ... Logger Configuration ...

var repoPath = Repository.Discover(Directory.GetCurrentDirectory());
if (string.IsNullOrEmpty(repoPath))
{
    Log.Error("Fatal: Not a Git repository. Aborting schema validation.");
    return 1; // Non-zero exit code to fail the commit
}

// Use a single Repository instance to avoid overhead.
using var repo = new Repository(repoPath);

// The Index represents the staging area.
var stagedChanges = repo.Diff.Compare<TreeChanges>(repo.Head.Tip?.Tree, DiffTargets.Index);

if (!stagedChanges.Any())
{
    Log.Information("No staged changes to validate. Skipping.");
    return 0;
}

Log.Information("Found {Count} staged files. Checking for GraphQL schema changes...", stagedChanges.Count());

// Filter for relevant schema files.
// In our convention, they all end with .graphql or .graphqls
var schemaFiles = stagedChanges
    .Where(change => change.Path.EndsWith(".graphql", StringComparison.OrdinalIgnoreCase) || 
                     change.Path.EndsWith(".graphqls", StringComparison.OrdinalIgnoreCase))
    .ToList();

if (!schemaFiles.Any())
{
    Log.Information("No GraphQL schema files in staged changes. Commit is safe to proceed.");
    return 0;
}

// Process each changed schema file...
// ...

A common mistake here is to only check for modified files. We must handle added, deleted, and renamed files. LibGit2Sharp‘s TreeChanges object provides a Status property for this. For our purpose, we are primarily concerned with Added and Modified files. A Deleted schema is a breaking change by definition, and the logic must account for that.

Phase 2: Fetching the Baseline Schema via GraphQL Introspection

Before we can validate a change, we need a baseline to compare against. This baseline is the schema currently active in our integration environment. We’ll build a dedicated service class to handle this communication using GraphQL.Client.

using GraphQL;
using GraphQL.Client.Http;
using GraphQL.Client.Serializer.SystemTextJson;

public class SchemaRegistryClient
{
    private readonly GraphQLHttpClient _client;
    private readonly ILogger _log;

    // The endpoint should be configurable in a real application.
    private const string SchemaRegistryUrl = "https://your-staging-gateway/graphql";

    public SchemaRegistryClient(ILogger logger)
    {
        _log = logger;
        var options = new GraphQLHttpClientOptions { EndPoint = new Uri(SchemaRegistryUrl) };
        _client = new GraphQLHttpClient(options, new SystemTextJsonSerializer());
    }

    public async Task<string?> GetIntrospectionSchemaAsync()
    {
        _log.Information("Fetching baseline schema from {Url}...", SchemaRegistryUrl);
        
        // This is the standard GraphQL introspection query.
        var request = new GraphQLRequest
        {
            Query = @"
                query IntrospectionQuery {
                  __schema {
                    queryType { name }
                    mutationType { name }
                    subscriptionType { name }
                    types {
                      ...FullType
                    }
                    directives {
                      name
                      description
                      locations
                      args {
                        ...InputValue
                      }
                    }
                  }
                }

                fragment FullType on __Type {
                  kind
                  name
                  description
                  fields(includeDeprecated: true) {
                    name
                    description
                    args {
                      ...InputValue
                    }
                    type {
                      ...TypeRef
                    }
                    isDeprecated
                    deprecationReason
                  }
                  inputFields {
                    ...InputValue
                  }
                  interfaces {
                    ...TypeRef
                  }
                  enumValues(includeDeprecated: true) {
                    name
                    description
                    isDeprecated
                    deprecationReason
                  }
                  possibleTypes {
                    ...TypeRef
                  }
                }

                fragment InputValue on __InputValue {
                  name
                  description
                  type { ...TypeRef }
                  defaultValue
                }

                fragment TypeRef on __Type {
                  kind
                  name
                  ofType {
                    kind
                    name
                    ofType {
                      kind
                      name
                      ofType {
                        kind
                        name
                        ofType {
                          kind
                          name
                          ofType {
                            kind
                            name
                            ofType {
                              kind
                              name
                              ofType {
                                kind
                                name
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
            "
        };

        try
        {
            var response = await _client.SendQueryAsync<object>(request);
            if (response.Errors?.Any() ?? false)
            {
                foreach(var error in response.Errors)
                {
                    _log.Error("GraphQL Error from registry: {Message}", error.Message);
                }
                return null;
            }

            // The introspection result needs to be converted back into a Schema Definition Language (SDL) string.
            // This is a complex task usually handled by a helper library or utility.
            // For this example, we'll assume a utility exists to do this conversion.
            // In a real implementation, you might use a library like `GraphQL.SystemTextJson.DocumentWriter` 
            // combined with `GraphQL.Utilities.SchemaPrinter`.
            var schemaSdl = ConvertIntrospectionResultToSdl(response.Data);
            _log.Information("Successfully fetched and processed baseline schema.");
            return schemaSdl;
        }
        catch (Exception ex)
        {
            _log.Error(ex, "Failed to connect to schema registry at {Url}. Network issue?", SchemaRegistryUrl);
            return null;
        }
    }
    
    // This is a placeholder for a complex conversion utility.
    private string ConvertIntrospectionResultToSdl(object data)
    {
        // In a real implementation, this would use GraphQL.NET utilities to print the schema.
        // For example:
        // var schema = Schema.For(JsonSerializer.Serialize(data));
        // var printer = new SchemaPrinter(schema);
        // return printer.Print();
        // This is a non-trivial step. For now, we assume it's done.
        return "type Query { placeholder: String }"; // FAKE IMPLEMENTATION
    }
}

The pitfall here is underestimating the complexity of converting the JSON introspection result back into a human-readable SDL string. Libraries like GraphQL.NET provide utilities (SchemaPrinter) for this, but it requires careful handling of the JSON structure. Another critical aspect is error handling. If the registry endpoint is unavailable, the hook must fail with a clear message explaining why the commit was blocked, rather than crashing silently.

Phase 3: The Core Logic - Structural Schema Comparison

This is where the real work happens. We need a service that can take two schema strings (the baseline and the proposed change), parse them into ASTs, and then systematically compare them to identify breaking changes.

graph TD
    A[Start] --> B{Get Staged File Content};
    B --> C{Fetch Baseline Schema};
    C --> D{Parse Both Schemas into ASTs};
    D --> E{Compare ASTs for Breaking Changes};
    E -- Breaking Change Found --> F[Log Error & Exit 1];
    E -- No Breaking Changes --> G[Log Success & Exit 0];
    A --> H{Error Handling};
    H --> F;

Our SchemaValidator will encapsulate this logic.

using GraphQLParser;
using GraphQLParser.AST;

public class SchemaValidator
{
    private readonly ILogger _log;

    public SchemaValidator(ILogger logger)
    {
        _log = logger;
    }

    public bool IsBreakingChange(string baselineSchemaSdl, string newSchemaSdl)
    {
        try
        {
            var baselineDocument = Parser.Parse(baselineSchemaSdl);
            var newDocument = Parser.Parse(newSchemaSdl);
            
            var breakingChanges = new List<string>();

            // Convert ASTs to a more easily searchable format, like a dictionary.
            var baselineTypes = baselineDocument.Definitions
                .OfType<GraphQLObjectTypeDefinition>()
                .ToDictionary(d => d.Name.Value);

            var newTypes = newDocument.Definitions
                .OfType<GraphQLObjectTypeDefinition>()
                .ToDictionary(d => d.Name.Value);

            // 1. Check for removed types
            foreach (var baselineTypeName in baselineTypes.Keys)
            {
                if (!newTypes.ContainsKey(baselineTypeName))
                {
                    breakingChanges.Add($"Type '{baselineTypeName}' was removed.");
                }
            }

            // 2. Check for changes within existing types
            foreach (var newTypePair in newTypes)
            {
                if (baselineTypes.TryGetValue(newTypePair.Key, out var baselineType))
                {
                    breakingChanges.AddRange(CompareObjectType(baselineType, newTypePair.Value));
                }
            }

            if (breakingChanges.Any())
            {
                _log.Error("CRITICAL: Breaking changes detected in schema!");
                foreach (var change in breakingChanges)
                {
                    _log.Error("  - {Change}", change);
                }
                return true;
            }

            _log.Information("Schema validation passed. No breaking changes found.");
            return false;
        }
        catch (GraphQLSyntaxErrorException ex)
        {
            _log.Error("Syntax Error: The proposed schema is not valid GraphQL. Details: {Message}", ex.Message);
            return true; // Invalid syntax is a blocking failure.
        }
    }

    private IEnumerable<string> CompareObjectType(GraphQLObjectTypeDefinition baseline, GraphQLObjectTypeDefinition proposed)
    {
        var baselineFields = baseline.Fields?.ToDictionary(f => f.Name.Value) ?? new();
        var proposedFields = proposed.Fields?.ToDictionary(f => f.Name.Value) ?? new();

        // Check for removed fields
        foreach (var baselineFieldName in baselineFields.Keys)
        {
            if (!proposedFields.ContainsKey(baselineFieldName))
            {
                yield return $"Field '{baseline.Name.Value}.{baselineFieldName}' was removed.";
            }
        }

        foreach (var proposedFieldPair in proposedFields)
        {
            if (baselineFields.TryGetValue(proposedFieldPair.Key, out var baselineField))
            {
                // Check for type changes. This requires a recursive type comparison.
                var baselineFieldType = GetBaseTypeName(baselineField.Type);
                var proposedFieldType = GetBaseTypeName(proposedFieldPair.Value.Type);
                if (baselineFieldType != proposedFieldType)
                {
                    yield return $"Field '{baseline.Name.Value}.{proposedFieldPair.Key}' changed type from '{baselineFieldType}' to '{proposedFieldType}'.";
                }

                // Check for change from nullable to non-nullable (a breaking change for clients).
                if (IsNullable(baselineField.Type) && !IsNullable(proposedFieldPair.Value.Type))
                {
                    yield return $"Field '{baseline.Name.Value}.{proposedFieldPair.Key}' became non-nullable.";
                }

                // Check for added required arguments
                var baselineArgs = baselineField.Arguments?.Where(a => a.Type is GraphQLNonNullType).ToDictionary(a => a.Name.Value) ?? new();
                var proposedArgs = proposedFieldPair.Value.Arguments?.Where(a => a.Type is GraphQLNonNullType).ToDictionary(a => a.Name.Value) ?? new();
                foreach (var proposedArgName in proposedArgs.Keys)
                {
                    if (!baselineArgs.ContainsKey(proposedArgName))
                    {
                        yield return $"Required argument '{proposedArgName}' was added to field '{baseline.Name.Value}.{proposedFieldPair.Key}'.";
                    }
                }
            }
        }
    }

    // Helper to traverse wrapper types (List, NonNull) to get the underlying named type.
    private string GetBaseTypeName(GraphQLType type)
    {
        return type switch
        {
            GraphQLNamedType namedType => namedType.Name.Value,
            GraphQLListType listType => GetBaseTypeName(listType.Type),
            GraphQLNonNullType nonNullType => GetBaseTypeName(nonNullType.Type),
            _ => "unknown"
        };
    }

    private bool IsNullable(GraphQLType type)
    {
        return type is not GraphQLNonNullType;
    }
}

This validation logic is a simplification. A production-grade implementation would need to handle enums, interfaces, unions, input types, and argument default value changes. The key principle is to define a strict set of rules for what constitutes a breaking change and implement checks for each one by traversing the AST.

Phase 4: Tying It All Together and Integration as a Git Hook

Now we update the main program flow to use these services. For each staged .graphql file, it will fetch the baseline, perform the validation, and exit with a non-zero status code if a breaking change is found. This failure prevents the git commit command from completing.

// In Program.cs, after finding schemaFiles

var schemaRegistryClient = new SchemaRegistryClient(Log.Logger);
var schemaValidator = new SchemaValidator(Log.Logger);

var baselineSchema = await schemaRegistryClient.GetIntrospectionSchemaAsync();
if (baselineSchema is null)
{
    Log.Error("Could not retrieve baseline schema. Cannot perform validation. Aborting commit.");
    return 1;
}

bool hasBreakingChange = false;

foreach (var change in schemaFiles)
{
    Log.Information("Validating {FilePath} ({Status})...", change.Path, change.Status);

    if (change.Status == ChangeKind.Deleted)
    {
        Log.Error("CRITICAL: Schema file '{FilePath}' was deleted, which is a breaking change.", change.Path);
        hasBreakingChange = true;
        continue;
    }

    // Get the content of the staged file.
    var blob = repo.Lookup<Blob>(change.Oid);
    var newSchemaContent = blob.GetContentText();

    if (schemaValidator.IsBreakingChange(baselineSchema, newSchemaContent))
    {
        hasBreakingChange = true;
    }
}

if (hasBreakingChange)
{
    Log.Warning("Commit REJECTED due to one or more breaking GraphQL schema changes.");
    return 1; // Signal failure
}

Log.Information("All schema validations passed. Proceeding with commit.");
return 0; // Signal success

To make this executable a Git hook, a simple script is placed in .git/hooks/pre-commit.

.git/hooks/pre-commit (for Linux/macOS):

#!/bin/sh
#
# Git pre-commit hook to run the .NET schema validator.

# Path to the validator project
VALIDATOR_PROJECT_PATH="./tools/SchemaValidator/SchemaValidator.csproj"

echo "--- Running GraphQL Schema Validator ---"
dotnet run --project "$VALIDATOR_PROJECT_PATH" --no-build
EXIT_CODE=$?

if [ $EXIT_CODE -ne 0 ]; then
  echo "--- Schema validation failed. Commit aborted. ---"
  exit 1
fi

echo "--- Schema validation passed. ---"
exit 0

This script must be executable (chmod +x .git/hooks/pre-commit). A similar .bat or .ps1 file would be used on Windows. Distributing this hook to an entire team is a separate challenge, often solved with a setup script in the repository that developers are instructed to run once.

The final tool, when triggered by a git commit, produces output directly in the terminal, giving the developer immediate feedback. For a failing commit, the output might be:

--- Running GraphQL Schema Validator ---
[10:30:01 INF] Found 3 staged files. Checking for GraphQL schema changes...
[10:30:01 INF] Validating schemas/user.graphql (Modified)...
[10:30:01 INF] Fetching baseline schema from https://your-staging-gateway/graphql...
[10:30:03 INF] Successfully fetched and processed baseline schema.
[10:30:03 ERR] CRITICAL: Breaking changes detected in schema!
[10:30:03 ERR]   - Field 'User.email' was removed.
[10:30:03 ERR]   - Required argument 'first' was added to field 'Query.users'.
[10:30:03 WRN] Commit REJECTED due to one or more breaking GraphQL schema changes.
--- Schema validation failed. Commit aborted. ---

This tool is not a silver bullet. A developer can still bypass it with git commit --no-verify. Therefore, it must be complemented by an identical check running in the main CI/CD pipeline, which cannot be bypassed. The pre-commit hook serves as a fast-feedback mechanism, a guardrail to improve developer productivity and reduce CI cycles, not as the ultimate gatekeeper.

The current implementation’s performance is tied to the network latency of the introspection query. For large teams, this could become a bottleneck. A potential optimization is to cache the baseline schema locally with a short time-to-live (TTL), only re-fetching it periodically. Furthermore, the tool’s configuration, such as the gateway URL, is hardcoded; a mature version would source this from a project-level configuration file (.graphqlvalidatorc) or Git configuration to allow for environment-specific validation endpoints. The definition of a “breaking change” itself might also need to be configurable, as some teams may have different compatibility promises.


  TOC