Seamlessly integrate strongly-typed primitives into your Umbraco apis



This content originally appeared on DEV Community and was authored by Dennis

Strongly-typed primitives are an essential part of Domain Driven Design. By giving descriptive types to primitives, you can effectively communicate the meaning of primitive values. Additionally, strongly-typed primitives give you compile-time guards against misuse of primitive types.

A strongly-typed primitive might look like this:

public readonly record struct UserID(Guid Value);

This clearly communicates that the Guid value is the unique identifier of a user. Other example use-cases for strongly-typed primitives are:

  • Emailaddresses
  • Stock Keeping Units
  • Version numbers
  • URLs

Strongly-typed primitives come with some unique challenges: The dotnet ecosystem cannot tell that your custom type is actually a primitive. You will need to configure your solution so that it knows how to work with your custom types. Doing this wrong can turn strongly-typed primitives into a big chore. In this blog I will demonstrate how I universally integrated strongly-typed primitives into Umbraco.

Example: messageboard api

For illustration, I’m going to use a simple messageboard api. This api allows one to post a message and then fetch it with a user-friendly URL or by ID. The complete sample project is posted on github:

GitHub logo D-Inventor / umbraco-strongly-typed-primitive-example

An example Umbraco 16 website that demonstrates how to work with strongly typed primitives

The focus is on the MessageEntity, which contains a strongly-typed ID, a title, a user-friendly slug and a strongly-typed reference to the author. It looks like this:

MessageEntity.cs

public class MessageEntity
{
    private MessageEntity()
    {
    }

    public MessageID Id { get; set; }

    public required Slug Slug { get; init; }

    public required string Title { get; init; }

    public PersonID AuthorId { get; init; }

    public static MessageEntity Create(string title, Slug slug, PersonID authorId)
        => new() { Title = title, Slug = slug, AuthorId = authorId };
}

public readonly record struct MessageID(Guid Value);
public readonly record struct PersonID(Guid Value);
public readonly record struct Slug
{
    public Slug(string value)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(value);
        if (value.Any(char.IsUpper)) throw new ArgumentException("A slug cannot contain uppercase characters", nameof(value));
        if (value.StartsWith('-') || value.EndsWith('-')) throw new ArgumentException("A slug cannot start or end with a dash", nameof(value));

        Value = value;
    }

    public string Value { get; }
}

The strongly-typed primitives are easy to recognize as they wrap around a single primitive-type, like Guid and string or int.

The api controller looks like this:

MessageApiController.cs

[Route("/api/[controller]")]
[ApiController]
public class MessageApiController(MessageRepository messageRepository) : ControllerBase
{
    private readonly MessageRepository _messageRepository = messageRepository;

    [HttpPost]
    [ProducesResponseType<CreateMessageResponse>(StatusCodes.Status201Created)]
    public IActionResult Create([FromBody] CreateMessageRequest request)
    {
        var message = MessageEntity.Create(request.Title, request.Slug, request.AuthorId);
        _messageRepository.Save(message);

        var apiUrl = new Uri(Url.ActionLink(nameof(GetById), values: new { messageId = message.Id })!);
        var friendlyUrl = new Uri(Url.ActionLink(nameof(GetBySlug), values: new { slug = message.Slug })!);
        return Created(apiUrl, new CreateMessageResponse(message.Id, friendlyUrl));
    }

    [HttpGet("{messageId:guid}")]
    [Produces<GetMessageResponse>]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public IActionResult GetById([FromRoute] MessageID messageId)
    {
        var message = _messageRepository.Find(messageId);

        return message is not null ? Ok(new GetMessageResponse(message.Id, message.Title, message.AuthorId))
            : NotFound();
    }

    [HttpGet("{slug}")]
    [Produces<GetMessageResponse>]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public IActionResult GetBySlug([FromRoute] Slug slug)
    {
        var message = _messageRepository.Find(slug);

        return message is not null ? Ok(new GetMessageResponse(message.Id, message.Title, message.AuthorId))
            : NotFound();
    }
}

These API endpoints are documented with a swagger UI:

The swagger UI displays three endpoints: A POST endpoint and two GET endpoints with route parameters

Problem #1: Strongly-typed primitives are seen as “complex objects” by default

Since strongly-typed primitives are classes and structs, the dotnet ecosystem treats them just like any other class or struct in the language. Before we can fix anything, we need to address this first. Modern dotnet allows us to specify how a strongly-typed primitive is constructed and deconstructed with an interface:

public interface IStronglyTypedPrimitive<TSelf, TPrimitive>
    where TSelf : IStronglyTypedPrimitive<TSelf, TPrimitive>
{
    static abstract TSelf Wrap(TPrimitive value);
    static abstract TPrimitive Unwrap(TSelf value);
}

Each strongly-typed primitive now needs to implement this interface. Additionally, each strongly-typed primitive should override the ToString() method:

public readonly record struct MessageID(Guid Value) : IStronglyTypedPrimitive<MessageID, Guid>
{
    public static Guid Unwrap(MessageID value) => value.Value;

    public static MessageID Wrap(Guid value) => new(value);

    public override string ToString() => Value.ToString();
}

public readonly record struct PersonID(Guid Value) : IStronglyTypedPrimitive<PersonID, Guid>
{
    public static Guid Unwrap(PersonID value) => value.Value;

    public static PersonID Wrap(Guid value) => new(value);

    public override string ToString() => Value.ToString();
}

public readonly record struct Slug : IStronglyTypedPrimitive<Slug, string>
{
    public Slug(string value)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(value);
        if (value.Any(char.IsUpper)) throw new ArgumentException("A slug cannot contain uppercase characters", nameof(value));
        if (value.StartsWith('-') || value.EndsWith('-')) throw new ArgumentException("A slug cannot start or end with a dash", nameof(value));

        Value = value;
    }

    public string Value { get; }

    public static string Unwrap(Slug value) => value.Value;

    public static Slug Wrap(string value) => new(value);

    public override string ToString() => Value;
}

Problem #2: Json (de-)serialization

Strongly-typed primitives should serialize and deserialize in json like the primitive type that they wrap. You can see in the HTTP Post body, that this is not the case:

{
  "title": "Hello world",
  "slug": {
    "value": "hello-world"
  },
  "authorId": {
    "value": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  }
}

The strongly-typed primitives behave like objects, which is ugly and undesirable. We can fix this by implementing JsonSerializer. For example for Guid:

StronglyTypedGuidJsonConverter.cs

// 👇 We only allow strongly-typed primitives that implement our interface
public class StronglyTypedGuidJsonConverter<TValue> : JsonConverter<TValue>
    where TValue : IStronglyTypedPrimitive<TValue, Guid>
{
    // 👇 Because of the interface, we can call the static methods on the type to wrap and unwrap the primitive value and serialize it.
    public override TValue? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        => TValue.Wrap(reader.GetGuid());

    public override void Write(Utf8JsonWriter writer, TValue value, JsonSerializerOptions options)
        => writer.WriteStringValue(TValue.Unwrap(value));
}

In order to use this type, we need to create a json serializer factory that can dynamically create instances of the json converter for each concrete implementation:

StronglyTypedPrimitiveJsonConverterFactory.cs

internal class StronglyTypedPrimitiveJsonConverterFactory : JsonConverterFactory
{
    // 👇 These are all the primitive types that we support with their corresponding converters.
    // All implementations can be found in the GitHub repo.
    private readonly Dictionary<Type, Type> _converterTypeMap = new()
    {
        { typeof(int), typeof(StronglyTypedIntJsonConverter<>) },
        { typeof(string), typeof(StronglyTypedStringJsonConverter<>) },
        { typeof(Guid), typeof(StronglyTypedGuidJsonConverter<>) }
    };

    // 👇 This factory can convert any type that implements the `IStronglyTypedPrimitive` interface.
    // Additionally, a converter must exist for the concrete primitive type
    public override bool CanConvert(Type typeToConvert)
    {
        var @interface = GetStronglyTypedPrimitiveInterface(typeToConvert);
        return @interface != null && _converterTypeMap.ContainsKey(GetPrimitiveType(@interface));
    }

    public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
    {
        var @interface = GetStronglyTypedPrimitiveInterface(typeToConvert);
        if (@interface is null) return null;

        if (!_converterTypeMap.TryGetValue(GetPrimitiveType(@interface), out var converterType)) return null;

        // 👇 Using reflection, a concrete JsonConverter type is constructed.
        // Based on the example in the documentation: https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/converters-how-to#sample-factory-pattern-converter
        var completeType = converterType.MakeGenericType(typeToConvert);
        var converter = Activator.CreateInstance(
            completeType,
            BindingFlags.Instance | BindingFlags.Public,
            binder: null,
            args: [],
            culture: null) as JsonConverter;

        return converter;
    }

    private static Type? GetStronglyTypedPrimitiveInterface(Type typeToConvert)
        => typeToConvert
        .GetInterfaces()
        .SingleOrDefault(@interface => @interface.IsGenericType && @interface.GetGenericTypeDefinition() == typeof(IStronglyTypedPrimitive<,>));

    private static Type GetPrimitiveType(Type @interface)
        => @interface.GetGenericArguments()[1];
}

We register this factory with the options pattern and dependency injection:

ConfigureStronglyTypedPrimitiveJsonOptions.cs

public class ConfigureStronglyTypedPrimitiveJsonOptions : IConfigureOptions<JsonOptions>
{
    public void Configure(JsonOptions options)
    {
        options.JsonSerializerOptions.Converters.Insert(0, new StronglyTypedPrimitiveJsonConverterFactory());
    }
}

Program.cs

// Umbraco's ootb configuration
builder.CreateUmbracoBuilder()
    .AddBackOffice()
    .AddWebsite()
    .AddComposers()
    .Build();

// 👇 Register options in DI
builder.Services.ConfigureOptions<ConfigureStronglyTypedPrimitiveJsonOptions>();

Now we can call the create endpoint with this body:

{
  "title": "Hello world",
  "slug": "hello-world",
  "authorId": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}

Problem #3: Modelbinding

Modelbinding is what you use to bind route parameters and query parameters to arguments on your action method in your controller. In our case, the two GET endpoints use modelbinding to either bind a MessageID or a Slug. If we attempt to call the api-endpoint with a valid slug or id, we get an error:

System.InvalidOperationException: Could not create an instance of type ‘MessageID’. Model bound complex types must not be abstract or value types and must have a parameterless constructor. Record types must have a single primary constructor. Alternatively, give the ‘messageId’ parameter a non-null default value.

In order to fix this, we implement IModelBinderProvider:

StronglyTypedPrimitiveModelBinderProvider.cs

public class StronglyTypedPrimitiveModelBinderProvider : IModelBinderProvider
{
    public IModelBinder? GetBinder(ModelBinderProviderContext context)
    {
        // 👇 We dynamically create modelbinders for each concrete type that implements IStronglyTypedPrimitive.
        // The implementation depends on the primitive type
        ArgumentNullException.ThrowIfNull(context);

        var modelType = context.Metadata.ModelType;
        var @interface = GetStronglyTypedPrimitiveInterface(modelType);
        if (@interface is null) return null;

        var primitiveType = GetPrimitiveType(@interface);
        if (primitiveType == typeof(string)) return CreateStringPrimitiveModelBinder(modelType);
        if (PrimitiveIsParsable(primitiveType)) return CreateParsablePrimitiveModelBinder(modelType, primitiveType);

        return null;
    }

    private static BinderTypeModelBinder CreateParsablePrimitiveModelBinder(Type modelType, Type primitiveType)
    {
        var modelBinderType = typeof(ParsablePrimitiveModelBinder<,>).MakeGenericType(modelType, primitiveType);
        return new BinderTypeModelBinder(modelBinderType);
    }

    private static BinderTypeModelBinder CreateStringPrimitiveModelBinder(Type modelType)
    {
        var modelBinderType = typeof(StringPrimitiveModelBinder<>).MakeGenericType(modelType);
        return new BinderTypeModelBinder(modelBinderType);
    }

    // 👇 Every dotnet primitive type implements the `IParsable<>` interface
    // That interface allows us to universally parse strings into primitive types
    private static bool PrimitiveIsParsable(Type primitiveType)
        => primitiveType
        .GetInterfaces()
        .Any(@interface => @interface.IsGenericType && @interface.GetGenericTypeDefinition() == typeof(IParsable<>));

    private static Type? GetStronglyTypedPrimitiveInterface(Type typeToConvert)
        => typeToConvert
        .GetInterfaces()
        .SingleOrDefault(@interface => @interface.IsGenericType && @interface.GetGenericTypeDefinition() == typeof(IStronglyTypedPrimitive<,>));

    private static Type GetPrimitiveType(Type @interface)
        => @interface.GetGenericArguments()[1];
}

The implementations for StringPrimitiveModelBinder<> and ParsablePrimitiveModelBinder<,> can be found in the repo on GitHub.

The model binder is registered with the options pattern in DI:

ConfigureStronglyTypedPrimitiveModelBinding.cs

public class ConfigureStronglyTypedPrimitiveModelBinding : IConfigureOptions<MvcOptions>
{
    public void Configure(MvcOptions options)
    {
        options.ModelBinderProviders.Insert(0, new StronglyTypedPrimitiveModelBinderProvider());
    }
}

Program.cs

builder.CreateUmbracoBuilder()
    .AddBackOffice()
    .AddWebsite()
    .AddComposers()
    .Build();

builder.Services.ConfigureOptions<ConfigureStronglyTypedPrimitiveJsonOptions>();
// 👇
builder.Services.ConfigureOptions<ConfigureStronglyTypedPrimitiveModelBinding>();

Problem #4: Open API docs

Although the API works as intended, our changes are not automatically picked up by the swagger documentation.

The swagger documentation still shows strongly-typed primitives as complex objects

We need to configure the swagger documentation so that it respects our strongly-typed primitives and presents them correctly. We apply the same tricks that we’ve done before, but now in a ISchemaFilter:

StronglyTypedPrimitiveSchemaFilter.cs

public class StronglyTypedPrimitiveSchemaFilter : ISchemaFilter
{
    private static readonly Dictionary<Type, (string type, string? format)> _primitiveTypeMap = new()
    {
        [typeof(int)] = ("integer", null),
        [typeof(string)] = ("string", null),
        [typeof(Guid)] = ("string", "uuid")
    };

    public void Apply(OpenApiSchema schema, SchemaFilterContext context)
    {
        var @interface = GetStronglyTypedPrimitiveInterface(context.Type);
        if (@interface is null || !_primitiveTypeMap.TryGetValue(GetPrimitiveType(@interface), out var schemaType)) return;

        // 👇 Some of these fields are set by Umbraco's configuration, some of them are just filled by default.
        // Anything that doesn't look like a primitive gets reset to default values
        // Additionally, the type and format are mapped to the primitive types
        schema.Properties.Clear();
        schema.Required.Clear();
        schema.OneOf.Clear();
        schema.AdditionalPropertiesAllowed = true;
        schema.Type = schemaType.type;
        schema.Format = schemaType.format;
    }

    private static Type? GetStronglyTypedPrimitiveInterface(Type type)
        => type
        .GetInterfaces()
        .SingleOrDefault(@interface => @interface.IsGenericType && @interface.GetGenericTypeDefinition() == typeof(IStronglyTypedPrimitive<,>));

    private static Type GetPrimitiveType(Type @interface)
        => @interface.GetGenericArguments()[1];
}

Once again, we register this type with the options pattern in DI:

ConfigureStronglyTypedPrimitiveOptions.cs

public class ConfigureStronglyTypedPrimitiveOptions : IConfigureOptions<SwaggerGenOptions>
{
    public void Configure(SwaggerGenOptions options)
    {
        options.SchemaFilter<StronglyTypedPrimitiveSchemaFilter>();
    }
}

Program.cs

builder.CreateUmbracoBuilder()
    .AddBackOffice()
    .AddWebsite()
    .AddComposers()
    .Build();

builder.Services.ConfigureOptions<ConfigureStronglyTypedPrimitiveJsonOptions>();
builder.Services.ConfigureOptions<ConfigureStronglyTypedPrimitiveModelBinding>();
// 👇
builder.Services.ConfigureOptions<ConfigureStronglyTypedPrimitiveOptions>();

When we return to the swagger UI, we see that it looks clean and correct:

The swagger documentation displays strongly-typed primitives as primitives after the changes

Final thoughts

I like the idea of strongly-typed primitives and I’ve already seen them work in practice. That being said: It has been quite a challenge to figure out how to properly integrate them into the edges of a software system. By the length of this blog, you can see that it takes a serious amount of effort to begin working with them. Although, now that I’ve figured it out, it would probably not take thát long anymore to repeat it.

I left a few things out of this blog for the sake of simplicity. One thing that I noticed for example, is that the user-friendly endpoint actually returns HTTP 500 when you don’t format your slug properly. I’ll leave that as a little exercise for you to fix.

I hope you learned something new today and I’ll see you in my next blog! 😊


This content originally appeared on DEV Community and was authored by Dennis