Dmitry Sikorsky

ASP.NET Core: How to Store, Display, and Edit Localizable and Multilingual User Content

I live in Kyiv, Ukraine. Historically we have 2 popular languages here: Ukrainian and Russian. So, any website or web application I create must have at least 2 language versions, single language doesn’t work at all. That’s why I spent some time to make it simpler to develop such websites and web applications.

While there are so many articles describing how to have localized UI in your ASP.NET Core web applications, there are very few of them which tell about how to store, edit, and display localizable and multilingual user content. It is not as simple task as it may seem at first glance. Let’s take a closer look at this interesting topic.

I assume that you have already created a multilingual web application (following this article for example), and it can show localized version of the UI (based on resources and views) depending on the culture selected by a user. BTW, which culture selection method should we use to determine user preferable language? I believe we should proceed from the fact whether search engines indexing of all the language versions and ability to share a link on a specific language version is required. If yes, then we must have some URL marker (a segment like /en/ or a parameter like ?culture=en), otherwise we can store current culture in cookies. The first option works well for the regular websites, while the second one is good for some backend systems. Anyway, it is not important for us how it is done in your case, it just has to work.

We will create a small demo project to illustrate the ideas of this article. It will contain a culture selector view component (selected culture will be stored in cookies), a book list (book name, description, and author properties will be localizable), and an option to add or edit books.

ASP.NET Core: How to Store, Display, and Edit Localizable and Multilingual User Content, Book List

ASP.NET Core: How to Store, Display, and Edit Localizable and Multilingual User Content, Add/Edit Book

To make everything as simple as possible, we won’t have any service layer in our sample web application. In the real-world applications we would probably operate domain model objects, convert them to/from view models and entities, but the main ideas could be the same.

Part One: Storing

Considering our example with the book list, our Book entity could look something like this:

It’s very straightforward. Looks simple, and it will work pretty fast (especially if you map it on a single language entity version and don’t load redundant data). But is it really so universal and easy to use? What if you have quite a lot of the entities localized in this way, and then you need to add (or remove) another one culture support? Or what if you have to support 10 different cultures? Obviously, in this case this approach won’t be very effective.

So, let’s change the Book entity in this way:

As you can see, now it is very clear that this implementation does not depend on the list of the supported cultures anymore. But what do all these properties mean?

Each localizable property (Name, Description, and Author) now is represented by a localization set (using a foreign key and a navigation property in terms of a relational database and Entity Framework Core respectively). The LocalizationSet entity looks like this:

We need the localization sets to be able to group localizations by a property (and assign them to the property in this way). Please take a look at the Localization entity:

Here the LocalizationSetId and CultureCode pair is always unique (primary key in terms of a relational database). Localization itself can’t refer to any specific entity (Book for example), because it is generic and designed to be used with any entity that needs to be localizable. And none of the entities can refer to the Localization one, because every localizable property contains several localizations (depending on the number of the supported cultures). That’s why we use localization sets.

And as we have the CultureCode foreign key (and because we should be able to get the supported cultures list) here is the Culture entity (where Code is unique primary key):

Our demo project on GitHub contains sample SQLite database with some test data you can play with.

Part Two: Displaying

Ok, we have some entities and the test database. Now let’s display our book list. Usually the only one language version is displayed for a user at the same time. This means that we shouldn’t load all the localizations to display a book, but one per property (for a given selected culture).

To make it simpler, we will add localized version of our Book entity:

As you can see, it looks like as we have regular single language application. We will use this entity when all we need is just to display a book. To avoid writing huge LINQ queries in a controller, we will also add the corresponding repository:

There are many ways to map our “real” Book entities to the LocalizedBook ones. One example:

This produces next SQL:

I would prefer something like this:

SQL is simpler (average execution time for 1000 requests was about 5 seconds in both cases, but our almost empty database is not the best choice for such test):

Now add a controller with a method like this one (please note how we pass current culture code inside the repository method):

We map our LocalizedBook to a Book view model (just to separate our data layer from a presentation one a bit).

And finally let’s display our books inside the view:

In the real-world application I probably would add CultureCode property to the LocalizedBook entity and create corresponding table inside the database. CultureCode and Id would be a primary key. This table could be automatically updated when book is created, updated, or deleted. This approach can significantly increase performance of select operations because doesn’t need localizations joining.

Part Three: Editing

And the last one, the most interesting part of the article. How to edit our localizable books?

First of all, let’s create a book add/edit page and corresponding view model, controller action, and view. We will begin from the view model first:

Everything is quite familiar here except the Localizable attributes. They are just empty attributes to mark localizable properties to make it possible to get them through reflection later:

As you may noticed, each localizable property paired with the localization list. This list (enumeration to be more precise) contains property localizations for each of the supported cultures. Localization is a view model too:

Ok. Now let’s see how we can initialize our view model inside the controller’s GET action to display the add/edit book page:

If we are going to create a new book (id is null), our localizations don’t contain any values yet, so we just create empty ones (according to the supported cultures list) using the CreateEmptyLocalizations method (it is defined inside our parent LocalizableController):

BTW. We could inject our ICultureRepository using the LocalizableController’s constructor, but in this case all the derived controllers would have to have this in their constructors too. So, we avoid that getting the repository service from the services collection directly.

In case we edit an existing book, first of all we should load that book using the repository method (this time we use another repository which operates “real” Book entities; we include localization sets and localizations to be selected together with the book):

Then we create our localizations by loading them from the database:

Now let’s create our view. In its final state it will look like this:

As you can see, some localizable-input tag helpers are used there. It is done to avoid duplications of the constructions like this one:

Our tag helper is quite simple. Here is its Process method:

I would like to draw your attention to the GetValue method:

This is very important to check model state’s AttemptedValue property, because otherwise entered value will be lost every time validation fails and page is reloaded.

Very good. At this moment we can display our page, it is even filled by an edited book data. The last thing is to save the changes when the form is sent to server. This is how the corresponding controller’s POST action looks like:

First of all, we check whether the model state is valid or not. If not, just redirect to the GET action and display the validation errors. Usually developers just return a view in such situations with the model passed as an argument. But in this case view model won’t contain any additional data, such drop down list items or something like that (because these properties are populated inside the GET action and are not sent back to a server). Our localization enumerations would be null too. We could restore them in this action before returning a view, but it would result in code duplication. Much better is just to redirect back to a GET action, restore user input from model state, and show the validation errors there. I believe you noticed the ExportModelStateToTempData and ImportModelStateFromTempData attributes above the actions. They do all the work.

Let’s back to our POST action. If the model is valid, we either create new Book entity, or load it from the database. Then we call LocalizableController’s CreateOrUpdateLocalizationsFor method. It is the most important one:

It gets all the entity properties that have LocalizationSet type, finds corresponding foreign key properties (for example, it is NameId for the Name one), gets values of that foreign key properties (localization set IDs), and delete (if present) and create localizations.

This is the GetOrCreateLocalizationSetForProperty method:

It is not very elegant, but we just concatenate localization set property name with the “Id” constant and get foreign key property name in this way to make it simple. Also we check whether a foreign key property value is null (localization set is not created yet) and then either create new localization set, or load one from a database.

Creating localizations is simple:

The only point of interest is how form value is got by combining property name and culture code.

The last thing I want to discuss with you is how to handle validation errors in our case. Please look at one of the localization property definitions in the view model:

All the attributes are applied to the Name property, but we don’t bind any input’s value in the view to this property (our inputs have names like NameEn and NameUk instead). We use it just to provide validation and other information (via attributes) to our localized inputs. In this case, because we don’t have any input named Name in the view, it’s value would always be null and model state would always be invalid. Further, as view model’s properties aren’t bound to the localized inputs, their attempted values aren’t saved inside model state when validation fails and aren’t restored with the validation errors. That’s very bad. To fix both of these issues we need next code inside our base LocalizableController:

It gets the view model (if present) passed to the given action, iterates through it’s properties marked with the Localizable attribute (we have defined it earlier), and removes our localizable properties from the model state validation. Then it gets localizable input values and validates them manually (I think there should be a better way to reuse built-in validators instead). We have implemented only required validation logic for example.

Now you can fill some inputs and left other ones empty and try to submit a form. You will see that empty inputs are highlighted while other ones preserved their values. Everything works as expected.

Conclusions

Sorry for such a long article, I hope it was interesting for you. I think there should be an easier way to bind localizable inputs to the localizations (maybe just bind them to an array of localization values), please let me know if you can help to improve that part. Also, I don’t like the way validation errors are handled by the localizable inputs (manually), perhaps we can reuse built-in validators. Anyway, there is a sample project available for you on my account on GitHub, please feel free to ask any questions, I would love to try to answer. See you next time.

Monday, July 15, 2019 by Dmitry Sikorsky