# How to Design Multi-Lingual Entity ## Introduction If you want to open up to the global market these days, end-to-end localization is a must. ABP provides an already established infrastructure for static texts. However, this may not be sufficient for many applications. You may need to fully customize your app for a particular language and region. Let's take a look at a few quotes from Christian Arno's article "[How Foreign-Language Internet Strategies Boost Sales](https://www.mediapost.com/publications/article/155250/how-foreign-language-internet-strategies-boost-sal.html)" to better understand the impact of this: - 82% of European consumers are less likely to buy online if the site is not in their native tongue ([Eurobarometer survey](http://europa.eu/rapid/pressReleasesAction.do?reference=IP/11/556)). - 72.4% of global consumers are more likely to buy a product if the information is available in their own language ([Common Sense Advisory](http://www.commonsenseadvisory.com/)). - The English language currently only accounts for 31% of all online use, and more than half of all searches are in languages other than English. - Today, 42% of all Internet users are in Asia, while almost one-quarter are in Europe and just over 10% are in Latin America. - Foreign languages have experienced exponential growth in online usage in the past decade -- with Chinese now officially the [second-most-prominent-language](http://english.peopledaily.com.cn/90001/90776/90882/7438489.html) on the Web. [Arabic](http://www.internetworldstats.com/stats7.htm) has increased by a whopping 2500%, while English has only risen by 204% If you are looking for ways to expand your market share by fully customizing your application for a particular language and region, in this article I will explain how you can do it with ABP framework. ### Source Code You can find the source code of the application at [abpframework/abp-samples](https://github.com/abpframework/abp-samples/tree/master/AcmeBookStoreMultiLingual). ### Demo of the Final Application At the end of this article, we will have created an application same as in the gif below. ![data-model](./result.gif) ## Development In order to keep the article short and get rid of unrelated information in the article (like defining entities etc.), we'll be using the [BookStore](https://github.com/abpframework/abp-samples/tree/master/BookStore-Mvc-EfCore) example, which is used in the "[Web Application Development Tutorial](https://docs.abp.io/en/abp/latest/Tutorials/Part-1?UI=MVC&DB=EF)" documentation of ABP Framework and we will make the Book entity as multi-lingual. If you do not want to finish this tutorial, you can find the application [here](https://github.com/abpframework/abp-samples/tree/master/BookStore-Mvc-EfCore). ### Determining the data model We need a robust, maintainable, and efficient data model to store content in multiple languages. > I read many articles to determine the data model correctly, and as a result, I decided to use one of the many approaches that suit us. > However, as in everything, there is a trade-off here. If you are wondering about the advantages and disadvantages of the model we will implement compared to other models, I recommend you to read [this article](https://vertabelo.com/blog/data-modeling-for-multiple-languages-how-to-design-a-localization-ready-system/). ![data-model](./data-model.png) As a result of the tutorial, we already have the `Book` and `Author` entities, as an extra, we will just add the `BookTranslation`. > In the article, we will make the Name property of the Book entity multi-lingual, but the article is independent of the Book entity, you can make the entity you want multi-lingual with similar codes according to your requirements. #### Acme.BookStore.Domain.Shared Create a folder named `MultiLingualObjects` and create the following interfaces in its contents. We will use the `IObjectTranslation` interface to mark the translation of a multi-lingual entity: ```csharp public interface IObjectTranslation { string Language { get; set; } } ``` We will use the `IMultiLingualObject` interface to mark multi-lingual entities: ```csharp public interface IMultiLingualObject where TTranslation : class, IObjectTranslation { ICollection Translations { get; set; } } ``` #### Acme.BookStore.Domain In the `Books` folder, create the `BookTranslation` class as follows: ```csharp public class BookTranslation : Entity, IObjectTranslation { public Guid BookId { get; set; } public string Name { get; set; } public string Language { get; set; } public override object[] GetKeys() { return new object[] {BookId, Language}; } } ``` `BookTranslation` contains the `Language` property, which contains a language code for translation and a reference to the multi-lingual entity. We also have the `BookId` foreign key to help us know which book is translated. Implement `IMultiLingualObject` in the `Book` class as follows: ```csharp public class Book : AuditedAggregateRoot, IMultiLingualObject { public Guid AuthorId { get; set; } public string Name { get; set; } public BookType Type { get; set; } public DateTime PublishDate { get; set; } public float Price { get; set; } public ICollection Translations { get; set; } } ``` Create a folder named `MultiLingualObjects` and add the following class inside of this folder: ```csharp public class MultiLingualObjectManager : ITransientDependency { protected const int MaxCultureFallbackDepth = 5; public async Task FindTranslationAsync( TMultiLingual multiLingual, string culture = null, bool fallbackToParentCultures = true) where TMultiLingual : IMultiLingualObject where TTranslation : class, IObjectTranslation { culture ??= CultureInfo.CurrentUICulture.Name; if (multiLingual.Translations.IsNullOrEmpty()) { return null; } var translation = multiLingual.Translations.FirstOrDefault(pt => pt.Language == culture); if (translation != null) { return translation; } if (fallbackToParentCultures) { translation = GetTranslationBasedOnCulturalRecursive( CultureInfo.CurrentUICulture.Parent, multiLingual.Translations, 0 ); if (translation != null) { return translation; } } return null; } protected TTranslation GetTranslationBasedOnCulturalRecursive( CultureInfo culture, ICollection translations, int currentDepth) where TTranslation : class, IObjectTranslation { if (culture == null || culture.Name.IsNullOrWhiteSpace() || translations.IsNullOrEmpty() || currentDepth > MaxCultureFallbackDepth) { return null; } var translation = translations.FirstOrDefault(pt => pt.Language.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)); return translation ?? GetTranslationBasedOnCulturalRecursive(culture.Parent, translations, currentDepth + 1); } } ``` With `MultiLingualObjectManager`'s `FindTranslationAsync` method, we get the translated version of the book according to `CurrentUICulture`. If no translation of culture is found, we return null. > Every thread in .NET has `CurrentCulture` and `CurrentUICulture` objects. #### Acme.BookStore.EntityFrameworkCore In the `OnModelCreating` method of the `BookStoreDbContext` class, configure the `BookTranslation` as follows: ```csharp builder.Entity(b => { b.ToTable(BookStoreConsts.DbTablePrefix + "BookTranslations", BookStoreConsts.DbSchema); b.ConfigureByConvention(); b.HasKey(x => new {x.BookId, x.Language}); }); ``` > I haven't explicitly set up a one-to-many relationship between `Book` and `BookTranslation` here, but the entity framework will do it for us. After that, you can just run the following command in a command-line terminal to add a new database migration (in the directory of the `EntityFrameworkCore` project): ```bash dotnet ef migrations add Added_BookTranslation ``` This will add a new migration class to your project. You can then run the following command (or run the `.DbMigrator` application) to apply changes to the database: ```bash dotnet ef database update ``` Add the following code to the `ConfigureServices` method of the `BookStoreEntityFrameworkCoreModule`: ```csharp Configure(options => { options.Entity(bookOptions => { bookOptions.DefaultWithDetailsFunc = query => query.Include(o => o.Translations); }); }); ``` Now we can use `WithDetailsAsync` without any parameters on `BookAppService` knowing that `Translations` will be included. #### Acme.BookStore.Application.Contracts Implement `IObjectTranslation` in the `BookDto` class as follows: ```csharp public class BookDto : AuditedEntityDto, IObjectTranslation { public Guid AuthorId { get; set; } public string AuthorName { get; set; } public string Name { get; set; } public BookType Type { get; set; } public DateTime PublishDate { get; set; } public float Price { get; set; } public string Language { get; set; } } ``` `Language` property is required to understand which language the translated book name belongs to in the UI. Create the `AddBookTranslationDto` class in the `Books` folder as follows: ```csharp public class AddBookTranslationDto : IObjectTranslation { [Required] public string Language { get; set; } [Required] public string Name { get; set; } } ``` Add the `AddTranslationsAsync` method to the `IBookAppService` as follows: ```csharp public interface IBookAppService : ICrudAppService< BookDto, Guid, PagedAndSortedResultRequestDto, CreateUpdateBookDto> { Task> GetAuthorLookupAsync(); Task AddTranslationsAsync(Guid id, AddBookTranslationDto input); // added this line } ``` #### Acme.BookStore.Application Now, we need to implement the `AddTranslationsAsync` method in `BookAppService` and include `Translations` in the `Book` entity, for this you can change the `BookAppService` as follows: ```csharp [Authorize(BookStorePermissions.Books.Default)] public class BookAppService : CrudAppService< Book, //The Book entity BookDto, //Used to show books Guid, //Primary key of the book entity PagedAndSortedResultRequestDto, //Used for paging/sorting CreateUpdateBookDto>, //Used to create/update a book IBookAppService //implement the IBookAppService { private readonly IAuthorRepository _authorRepository; public BookAppService( IRepository repository, IAuthorRepository authorRepository) : base(repository) { _authorRepository = authorRepository; GetPolicyName = BookStorePermissions.Books.Default; GetListPolicyName = BookStorePermissions.Books.Default; CreatePolicyName = BookStorePermissions.Books.Create; UpdatePolicyName = BookStorePermissions.Books.Edit; DeletePolicyName = BookStorePermissions.Books.Create; } public override async Task GetAsync(Guid id) { //Get the IQueryable from the repository var queryable = await Repository.WithDetailsAsync(); // this line changed //Prepare a query to join books and authors var query = from book in queryable join author in await _authorRepository.GetQueryableAsync() on book.AuthorId equals author.Id where book.Id == id select new { book, author }; //Execute the query and get the book with author var queryResult = await AsyncExecuter.FirstOrDefaultAsync(query); if (queryResult == null) { throw new EntityNotFoundException(typeof(Book), id); } var bookDto = ObjectMapper.Map(queryResult.book); bookDto.AuthorName = queryResult.author.Name; return bookDto; } public override async Task> GetListAsync(PagedAndSortedResultRequestDto input) { //Get the IQueryable from the repository var queryable = await Repository.WithDetailsAsync(); // this line changed //Prepare a query to join books and authors var query = from book in queryable join author in await _authorRepository.GetQueryableAsync() on book.AuthorId equals author.Id select new {book, author}; //Paging query = query .OrderBy(NormalizeSorting(input.Sorting)) .Skip(input.SkipCount) .Take(input.MaxResultCount); //Execute the query and get a list var queryResult = await AsyncExecuter.ToListAsync(query); //Convert the query result to a list of BookDto objects var bookDtos = queryResult.Select(x => { var bookDto = ObjectMapper.Map(x.book); bookDto.AuthorName = x.author.Name; return bookDto; }).ToList(); //Get the total count with another query var totalCount = await Repository.GetCountAsync(); return new PagedResultDto( totalCount, bookDtos ); } public async Task> GetAuthorLookupAsync() { var authors = await _authorRepository.GetListAsync(); return new ListResultDto( ObjectMapper.Map, List>(authors) ); } public async Task AddTranslationsAsync(Guid id, AddBookTranslationDto input) { var queryable = await Repository.WithDetailsAsync(); var book = await AsyncExecuter.FirstOrDefaultAsync(queryable, x => x.Id == id); if (book.Translations.Any(x => x.Language == input.Language)) { throw new UserFriendlyException($"Translation already available for {input.Language}"); } book.Translations.Add(new BookTranslation { BookId = book.Id, Name = input.Name, Language = input.Language }); await Repository.UpdateAsync(book); } private static string NormalizeSorting(string sorting) { if (sorting.IsNullOrEmpty()) { return $"book.{nameof(Book.Name)}"; } if (sorting.Contains("authorName", StringComparison.OrdinalIgnoreCase)) { return sorting.Replace( "authorName", "author.Name", StringComparison.OrdinalIgnoreCase ); } return $"book.{sorting}"; } } ``` Create the `MultiLingualBookObjectMapper` class as follows: ```csharp public class MultiLingualBookObjectMapper : IObjectMapper, ITransientDependency { private readonly MultiLingualObjectManager _multiLingualObjectManager; private readonly ISettingProvider _settingProvider; public MultiLingualBookObjectMapper( MultiLingualObjectManager multiLingualObjectManager, ISettingProvider settingProvider) { _multiLingualObjectManager = multiLingualObjectManager; _settingProvider = settingProvider; } public BookDto Map(Book source) { var translation = AsyncHelper.RunSync(() => _multiLingualObjectManager.FindTranslationAsync(source)); return new BookDto { Id = source.Id, AuthorId = source.AuthorId, Type = source.Type, Name = translation?.Name ?? source.Name, PublishDate = source.PublishDate, Price = source.Price, Language = translation?.Language ?? AsyncHelper.RunSync(() => _settingProvider.GetOrNullAsync(LocalizationSettingNames.DefaultLanguage)), CreationTime = source.CreationTime, CreatorId = source.CreatorId, LastModificationTime = source.LastModificationTime, LastModifierId = source.LastModifierId }; } public BookDto Map(Book source, BookDto destination) { return default; } } ``` To map the multi-lingual `Book` entity to `BookDto`, we implement custom mapping using the `IObjectMapper` interface. If no translation is found, default values are returned. So far we have created the entire infrastructure. We don't need to change anything in the UI, if there is a translation according to the language chosen by the user, the list view will change. However, I want to create a simple modal where we can add new translations to an existing book in order to see what we have done. #### Acme.BookStore.Web Create a new razor page named `AddTranslationModal` in the `Books` folder as below. **View** ```html @page @using Microsoft.AspNetCore.Mvc.TagHelpers @using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal @model Acme.BookStore.Web.Pages.Books.AddTranslationModal @{ Layout = null; }
Translations
``` **Model** ```csharp public class AddTranslationModal : BookStorePageModel { [HiddenInput] [BindProperty(SupportsGet = true)] public Guid Id { get; set; } public List Languages { get; set; } [BindProperty] public BookTranslationViewModel TranslationViewModel { get; set; } private readonly IBookAppService _bookAppService; private readonly ILanguageProvider _languageProvider; public AddTranslationModal( IBookAppService bookAppService, ILanguageProvider languageProvider) { _bookAppService = bookAppService; _languageProvider = languageProvider; } public async Task OnGetAsync() { Languages = await GetLanguagesSelectItem(); TranslationViewModel = new BookTranslationViewModel(); } public async Task OnPostAsync() { await _bookAppService.AddTranslationsAsync(Id, ObjectMapper.Map(TranslationViewModel)); return NoContent(); } private async Task> GetLanguagesSelectItem() { var result = await _languageProvider.GetLanguagesAsync(); return result.Select( languageInfo => new SelectListItem { Value = languageInfo.CultureName, Text = languageInfo.DisplayName + " (" + languageInfo.CultureName + ")" } ).ToList(); } public class BookTranslationViewModel { [Required] [SelectItems(nameof(Languages))] public string Language { get; set; } [Required] public string Name { get; set; } } } ``` Then, we can open the `BookStoreWebAutoMapperProfile` class and define the required mapping as follows: ```csharp CreateMap(); ``` Finally, change the content of `index.js` in the `Books` folder as follows: ```javascript $(function () { var l = abp.localization.getResource('BookStore'); var createModal = new abp.ModalManager(abp.appPath + 'Books/CreateModal'); var editModal = new abp.ModalManager(abp.appPath + 'Books/EditModal'); var addTranslationModal = new abp.ModalManager(abp.appPath + 'Books/AddTranslationModal'); // added this line var dataTable = $('#BooksTable').DataTable( abp.libs.datatables.normalizeConfiguration({ serverSide: true, paging: true, order: [[1, "asc"]], searching: false, scrollX: true, ajax: abp.libs.datatables.createAjax(acme.bookStore.books.book.getList), columnDefs: [ { title: l('Actions'), rowAction: { items: [ { text: l('Edit'), visible: abp.auth.isGranted('BookStore.Books.Edit'), action: function (data) { editModal.open({ id: data.record.id }); } }, { text: l('Add Translation'), // added this action visible: abp.auth.isGranted('BookStore.Books.Edit'), action: function (data) { addTranslationModal.open({ id: data.record.id }); } }, { text: l('Delete'), visible: abp.auth.isGranted('BookStore.Books.Delete'), confirmMessage: function (data) { return l( 'BookDeletionConfirmationMessage', data.record.name ); }, action: function (data) { acme.bookStore.books.book .delete(data.record.id) .then(function() { abp.notify.info( l('SuccessfullyDeleted') ); dataTable.ajax.reload(); }); } } ] } }, { title: l('Name'), data: "name" }, { title: l('Author'), data: "authorName" }, { title: l('Type'), data: "type", render: function (data) { return l('Enum:BookType:' + data); } }, { title: l('PublishDate'), data: "publishDate", render: function (data) { return luxon .DateTime .fromISO(data, { locale: abp.localization.currentCulture.name }).toLocaleString(); } }, { title: l('Price'), data: "price" }, { title: l('CreationTime'), data: "creationTime", render: function (data) { return luxon .DateTime .fromISO(data, { locale: abp.localization.currentCulture.name }).toLocaleString(luxon.DateTime.DATETIME_SHORT); } } ] }) ); createModal.onResult(function () { dataTable.ajax.reload(); }); editModal.onResult(function () { dataTable.ajax.reload(); }); $('#NewBookButton').click(function (e) { e.preventDefault(); createModal.open(); }); }); ``` ## Conclusion With a multi-lingual application, you can expand your market share, but if not designed well, may your application will be unusable. So, I've tried to explain how to design a sustainable multi-lingual entity in this article. ### Source Code You can find source code of the example solution used in this article [here](https://github.com/abpframework/abp-samples/tree/master/AcmeBookStoreMultiLingual).