DEV Community

Dennis
Dennis

Posted on

Driving Umbraco's dictionary to the edge (of your system)

Umbraco's translations section allows you to define reusable pieces of flat text. It's an easy way to give a place to small texts and labels that a content editor shouldn't specify manually on every page.
Though it's easy to set up and get started, you may quickly run into challenges when dealing with translated texts in medium size applications. When producing models in the service layer for example, it's not always possible or desirable to consume the UmbracoHelper to access the translations. I've seen some nasty workarounds to access the dictionary and in this post, I would like to propose an alternative approach.

The goal

The goal of this post is to refactor some code that depends on Umbraco's translations in the service layer. At the end, the dependency on Umbraco's translations will have been moved to the razor view. What is important is that the approach is as non-disruptive as possible and easy to read and understand.

ℹī¸ Umbraco version
The code in this post is built on Umbraco 15, but also works in Umbraco 13.

The starting point

For the starting point, we'll make a few assumptions:

  • We have a domainmodel for a link with a text and an alt text for screenreaders
  • We have a contentpage that optionally allows you to set the text on the link
  • We have a service that produces the link domainmodel with the text.
  • we have a razor partial view that consumes the link and uses it to display a button.

LinkDomainModel.cs

public record LinkDomainModel(string Url, string Text, string Alt);
Enter fullscreen mode Exit fullscreen mode

MyService.cs

public class MyService(ICultureDictionaryFactory dictionaryFactory)
{
    private ICultureDictionary Dictionary => dictionaryFactory.CreateDictionary();

    public LinkDomainModel GetLinkFromContent(IPublishedContent content)
    {
        var contentToLinkTo = content.Value<IPublishedContent>("linkContent");
        var linkLabel = content.Value<string>("linkLabel");
        if (string.IsNullOrWhiteSpace(linkLabel))
        {
            linkLabel = Dictionary["link label default"];
        }

        var linkAlt = string.Format(Dictionary["link label alttext"], contentToLinkTo.Name);

        return new LinkDomainModel(
            contentToLinkTo.Url(),
            linkLabel,
            linkAlt);
    }
}
Enter fullscreen mode Exit fullscreen mode

ButtonLink.cshtml

@inherits UmbracoViewPage<LinkDomainModel>

<a class="btn btn-primary" href="@Model.Url" alt="@Model.Alt">
    @Model.Text
</a>
Enter fullscreen mode Exit fullscreen mode

The problem with the code

The service class is handling multiple responsibilities. It's responsible for producing link models, but also for translating labels and alttext with Umbraco's translations. This service cannot depend on UmbracoHelper, so it uses a workaround to access the translations. We cannot fix this problem, because the link domainmodel requires us to pass the labels as string: The model suffers from primitive obsession.

The fix

To solve the problem, we need to find a way to transfer the responsibility of translating the text to the consumer of the LinkDomainModel. We do this by removing the dependency on strings in several steps.

Step 1: Introduce a strongly typed model

The first step is to introduce a strongly typed model. Initially, the model will simply mimic the behaviour of a string. That allows us to replace the string values with our model, without disrupting the rest of the system. We'll create a model and update LinkDomainModel:

Label.cs

public record Label(string Value)
{
    // 👇 By overriding ToString, our model will render in razor as if it was just a plain string value
    // This way, the existing behaviour inside views is preserved
    public override string? ToString() => Value;

    // 👇 By implicitly casting strings to this type,
    // we can pass string values into fields that require type Label
    // This preserves the code inside the service that creates the model
    public static implicit operator Label(string value)
        => new(value);
}
Enter fullscreen mode Exit fullscreen mode

LinkDomainModel.cs

public record LinkDomainModel(string Url, Label Text, Label Alt);
Enter fullscreen mode Exit fullscreen mode

This change may seem insignificant and perhaps even unnecessary, but it gives us the needed advantage for step 2:

Step 2: Separating static and translatable text

Now that we have a model, we can start introducing some polymorphism. This will allow us to make a distinction between static text and translatable texts. We update the Label class as follows:

Label.cs

public abstract record Label()
{
    // 👇 This call is changed so it now returns a StaticLabel.
    public static implicit operator Label(string value)
        => Text(value);

    // 👇 This static factory abstracts the translatable label,
    // so the rest of the application doesn't need to know that it exists
    public static Label Translate(string key, params object?[] parameters)
        => new TranslatableLabel(key, parameters);

    // 👇 This static factory abstracts the static text implementation
    public static Label Text(string value)
        => new StaticLabel(value);
}

// 👇 We move the value parameter to the static label
// We also move the ToString override to here, along with the string value
public record StaticLabel(string Value) : Label
{
    public override string ToString() => Value;
}

// 👇 We introduce a new type of label that can hold a dictionary key and an optional set of parameters
public record TranslatableLabel(string Key, params object?[] Parameters) : Label;
Enter fullscreen mode Exit fullscreen mode

Still at this point, nothing has changed, but we can now differentiate between static and translatable texts. This allows us to start moving the responsibilities from the service layer to the razor view.

Step 3: Remove the dependency from the service layer

To remove the dependency, we will need to make some disruptive changes in our view. Let's start by changing the service layer:

ExampleService.cs

public static class ExampleService
{
    public static LinkDomainModel GetLinkFromContent(this IPublishedContent content)
    {
        var contentToLinkTo = content.Value<IPublishedContent>("linkContent");
        string? labelString = content.Value<string>("linkLabel");
        Label linkLabel = !string.IsNullOrWhiteSpace(labelString)
            ? Label.Text(labelString)
            : Label.Translate("link label default");

        var linkAlt = Label.Translate("link label alttext", contentToLinkTo.Name);

        return new LinkDomainModel(
            contentToLinkTo.Url(),
            linkLabel,
            linkAlt);
    }
}
Enter fullscreen mode Exit fullscreen mode

By explicitly using the Label class, we were able to completely remove the dependency on the umbraco dictionary. We could even make the class static and turn the method into an extension method.

This change has broken the razor views and we will have to make a change to the razor view to make it work. To fix this, we first make an extension method on UmbracoViewPage<TModel>:

LabelExtensions.cs

public static class LabelExtensions
{
    public static string? ToText<TModel>(this UmbracoViewPage<TModel> page, Label label)
        => label switch
        {
            // 👇 Check which type of label we've found and convert it to text accordingly
            TranslatableLabel translatableLabel => page.Translate(translatableLabel),
            StaticLabel staticLabel => staticLabel.Value,
            _ => throw new UnreachableException()
        };

    private static string? Translate<TModel>(this UmbracoViewPage<TModel> page, TranslatableLabel label)
        => (page.Umbraco.GetDictionaryValue(label.Key)?.Trim(), label.Parameters) switch
        {
            (null or "", _) => null,
            (var text, null or []) => text,
            (var text, var parameters) => string.Format(text, parameters)
        };
}
Enter fullscreen mode Exit fullscreen mode

You can choose one of two ways to fix the partial view. You can simply use this method directly inside the view:

ButtonLink.cshtml

@inherits UmbracoViewPage<LinkDomainModel>

<a class="btn btn-primary" href="@Model.Url" alt="@this.ToText(Model.Alt)">
    @this.ToText(Model.Text)
</a>
Enter fullscreen mode Exit fullscreen mode

Alternatively, you can create a new class that inherits from UmbracoViewPage<TModel>:

CustomViewPage.cs

public abstract class CustomViewPage<TModel> : UmbracoViewPage<TModel>
{
    // 👇 Whenever we come across one of our labels inside a razor view,
    // we apply the transform before passing it to the base implementation
    public override void Write(object? value)
        => base.Write(value is Label label ? this.ToText(label) : value);
}
Enter fullscreen mode Exit fullscreen mode

If you do this, you can make a more subtle change to your razor view:

ButtonLink.cshtml

@* 👇 This page now uses a custom base class *@
@inherits CustomViewPage<LinkDomainModel>

<a class="btn btn-primary" href="@Model.Url" alt="@Model.Alt">
    @Model.Text
</a>
Enter fullscreen mode Exit fullscreen mode

With this CustomViewPage, we can use our labels the same way as we would a simple string, but still automatically translate all dictionary keys with the Umbraco translations. You can't even see in your razor view that you're using Umbraco's translations.

Recap

We started with an application that had a dependency on the Umbraco translations inside the service layer. In a series of steps, we updated our model so that we can move the responsibility of translating keys to the razor view. We ended up with a model that allows us to gradually remove Umbraco's translations from the service layer. You can find a full working example on my GitHub.

Acknowledgement

This post was heavily inspired by Zoran Horvat on youtube. They explain what primitive obsession is and present a similar process to eliminate it from business applications.

Final thoughts

I thought this trick was pretty neat. I see a lot of potential for this approach to translations and I think it's a good solution to isolate the responsibility for translating text inside razor views, where it belongs.

On the other hand, I have also been told that this is excessive and that having Umbraco translations in the service layer isn't really a problem. What do you think of this? I would love to read your thoughts in the comments!

Thank you for reading and I'll see you in my next blog! 😊

Top comments (0)