Skip to content
Matthew Adams By Matthew Adams Co-Founder
Json Schema Patterns in .NET - Interfaces and mix-in types

In this series we are cataloging common patterns with JSON Schema and the .NET code generated by Corvus.JsonSchema.

It is especially useful for developers who want to move to a schema-first approach, but are unsure how that will map to their .NET code.

We are focusing on draft 2020-12 and the dotnet8.0 code generation, but very similar patterns apply for older versions of both .NET and draft 2019-09, draft 7, and even draft 6. We will highlight the key differences as we go.

If you have no experience of JSON Schema at all, I would recommend you read the getting started step-by-step documentation provided by the JSON Schema team.

Composing interfaces v. mix-in types with allOf.

.NET does not support the concept of multiple-inheritance or mix-ins.

However, you can implement multiple interfaces on a type.

While interfaces don't (generally) provide implementation (though that has changed!), they do provide structure, and semantic intent. This gives us the ability to define functions that operate on a particular interface, without having to know the details of the specific instance.

The equivalent in JSON Schema is to compose multiple schema using the allOf keyword.

allOf lets us provide an array of schema. As the name implies all of the schema constraints are applied: both our local constraints, and those in each of the schema in the allOfarray.

You have to take care to ensure that they are mutually compatible, or you can get unexpected validation failures. (We can't be allOf a {"type": "string"} and a {"type": "object"}!).

File: composite-type.json

{
    "title": "A composition of multiple different schema",
    "type": "object",
    "allOf": [
        { "$ref": "./countable.json" },
        { "$ref": "./documentation.json" }
    ],
    "required": [ "budget" ],
    "properties": {
        "budget": { "$ref": "#/$defs/currencyValue" }
    },
    "$defs": {
        "currencyValue": {
            "type": "number",
            "format": "decimal"
        }
    }
}

File: documentation.json

{
    "type": "object",
    "required": [ "title" ],
    "properties": {
        "description": { "type": "string" },
        "title": { "type": "string" }
    }
}

File: countable.json

{
    "type": "object",
    "required": [ "count" ],
    "properties": {
        "count": { "$ref": "#/$defs/positiveInt32" }
    },
    "$defs": {
        "positiveInt32": {
            "type": "integer",
            "format": "int32"
        }
    }
}

In our example, we are saying that our composed schema conforms to both the countable, and documentation schemas.

We have represented these as externally provided schema documents for the example. As always, if they are under your control, you might choose to embed them locally in a single document.

We then add our own additional constraints - in this case an extra required property called budget.

Generate the code:

generatejsonschematypes --outputPath Model --rootNamespace JsonSchemaSample.Api composite-type.json
Generating: Documentation
Generating: Countable
Generating: CompositeType

It has generated our types in the usual way. Notice that there is no CurrencyValue. You might have expected to see that because we define a schema in $defs and then $ref it. Usually that would cause a type to be generated.

However, the code generator has detected that the final definition reduces to a built-in type (JsonDecimal). In this case it avoids generating unnecessary code.

Example code

using Corvus.Json;
using JsonSchemaSample.Api;

// Create a composite type. Required and optional properties are composed from all participating schema.
CompositeType composite =
    CompositeType.Create(123.7m, 4, "Greeting", "Hello world");
// Example omitting the description.
CompositeType composite2 =
    CompositeType.Create(1_438_274.3m, 3, "Salutation");

// Implicit conversion to each of the composed types,
// Notice how this is similar to implementing multiple interfaces
Console.WriteLine(FormatDocumentation(composite));
Console.WriteLine(FormatCount(composite));

Console.WriteLine(FormatDocumentation(composite2));
Console.WriteLine(FormatCount(composite2));

// Note that when we define functions over our types,
// we tend to pass parameters with the "in"
// modifier in order to avoid unnecessary copying.
string FormatDocumentation(in Documentation documentation)
{
    if (documentation.Description.IsNotUndefined())
    {
        return $"{documentation.Title}: {documentation.Description}";
    }
    else
    {
        return (string)documentation.Title;
    }
}

string FormatCount(in Countable countable)
{
    return $"Count: {countable.Count}";
}

Matthew Adams

Co-Founder

Matthew Adams

Matthew was CTO of a venture-backed technology start-up in the UK & US for 10 years, and is now the co-founder of endjin, which provides technology strategy, experience and development services to its customers who are seeking to take advantage of Microsoft Azure and the Cloud.