ASP.NET8 – View-Component primer

ASP.NET8 – View-Component primer

We show reusable code on how to create ASP.NET8 View-Components.

Abstract: We show how to create ASP.NET8 View-Components, which are a more powerful form of component architecture than popular Partial-Views. An example with reusable code is shown.

1 ASP.NET8 View-Components are more powerful than Partial-Views

Simply speaking, ASP.NET8 View-Components are similar to Partial-Views, but enable a higher level of componentization and functionality encapsulation. They are intended for ASP.NET8 MVC or Razor Pages architecture.

ASP.NET8 View-Components are intended for the creation of reusable components that have View/Render functionality, and whose complexity is greater than normal Partial-Views.

Key advantage over ASP.NET Partial-Views is that View-Components have a component part that is server-side executed and can access all the application architecture mechanisms, like Database Access Layer (for example EF) independently from the Host page where they are hosted. That enables quite elaborate and complex processing that can be done and componentized into a reusable form.

ASP.NET8 View-Components receive parameters from the calling method (host page or even controller directly) and not HTTP request. But, of course, nothing prevents the creation of View-Components that receive HttpRequest as a parameter that will be explicitly passed to the component.

View components in ASP.NET Core are well defined in the article [1], and I will not repeat it all here. The plan is to provide working C# sample code (“Primer”) on how view components typically can be used. Reusable code with a simple example is shown below. I added a decent quantity of comments, so it should be self-explanatory how they work.

2 Final Result

Here we show the final result. There is ASP.NET8 host page has 2 components and 4 invocations shown on the picture. Components are colored deliberately to show where they are. In a real app, of course, coloring would not be used. Here is what this example shows:

  • Div-0. Control in Host ASP.NET8 page
  • Div-1. View-Component-1 CustomerSearch, which is Async component, invoked with a method
  • Div-2. View-Component-1 CustomerSearch, which is Async component, invoked with Tag Helper
  • Div-3. View-Component-2 CustomerSearch2, which is Sync component, invoked with a method
  • Div-4. View-Component-2 CustomerSearch2, which is Sync component, invoked with Tag Helper

3 Source Code for this Example

The source code is well commented, and it should be self-explanatory.

3.1 C# part

HomeController.cs
The controller provides three endpoints: Index, Test1, and Error.
It uses dependency injection for logging.
It returns views for each action, optionally passing models.
The Test1 action is specifically for testing a ViewComponent with a model.

CustomersSearchViewComponent.cs
This code defines an asynchronous View Component for a Razor Pages project that displays a list of customers. It uses dependency injection to receive a logger, accepts an optional model with parameters, prepares a view model with those parameters and a hardcoded customer list, and then renders a view with that data. The component is designed to be reusable and modular for customer search or selection features. Asynchronous.

CustomersSearch2ViewComponent.cs
This code defines a synchronous View Component for a Razor Pages project that displays a list of customers. It uses dependency injection to receive a logger, accepts an optional model with parameters, prepares a view model with those parameters and a hardcoded customer list, and then renders a view with that data. The component is designed to be reusable and modular for customer search or selection features, and operates synchronously rather than asynchronously.


//HomeController.cs=========================================
namespace Example1.Controllers
{
    public class HomeController : Controller
    {
        private readonly ILogger<HomeController> _logger;

        public HomeController(ILogger<HomeController> logger)
        {
            _logger = logger;
        }

        public IActionResult Index()
        {
            return View();
        }

        //in this action we are testing the ViewComponent
        public IActionResult Test1(Test1_ViewModel model)
        {
            return View(model);
        }

        [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
        public IActionResult Error()
        {
            return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
        }
    }
}

// CustomersSearchViewComponent_Model.cs ==========================
namespace Example1.ViewComponents.Models
{
    public class CustomersSearchViewComponent_Model
    {
        public string? Parameter1String { get; set; } = null;
        public int? Parameter2Int { get; set; } = null;
        public string? IdOfTargetInputField { get; set; } = null;
    }
}

// CustomersSearchViewComponent_ViewModel.cs ==========================
namespace Example1.ViewComponents.Models
{
    public class CustomersSearchViewComponent_ViewModel
    {
        public string? Parameter1String { get; set; } = null;
        public int? Parameter2Int { get; set; } = null;
        public string? IdOfTargetInputField { get; set; } = null;

        //this should come from DB in real application
        public SelectList? ListOfCustomers { get; set; }
    }
}

//CustomersSearch2ViewComponent.cs===================================================
namespace Example1.ViewComponents
{
    //it should inherit from ViewComponent class
    //this is Sync version of ViewComponent
    public class CustomersSearch2ViewComponent : ViewComponent
    {
        private readonly ILogger<CustomersSearch2ViewComponent>? _logger;

        // We are testing Dependency Injection (DI) to inject the logger 
        public CustomersSearch2ViewComponent(ILogger<CustomersSearch2ViewComponent> logger)
        {
            //note how useful is this, we are passing in this constructor
            //into view-component objects that are part of app DI container
            //you can pass here any form of reference to DataBase, for example
            //like EF DbContext or similar
            _logger = logger;
        }

        //The Invoke method for the View component
        public IViewComponentResult Invoke(
            CustomersSearchViewComponent_Model? model = null
        )
        {
            //this is neat, this parameters in CustomersSearchViewComponent_Model
            //are passed to the view-component from host form
            {
                //testing that DI worked - logger 
                string methodName = $"Type: {System.Reflection.MethodBase.GetCurrentMethod()?.DeclaringType?.FullName}, " +
                    $"Method: InvokeAsync; ";
                _logger?.LogWarning(methodName);
            }

            //preparing view-model
            CustomersSearchViewComponent_ViewModel viewModel = new();
            viewModel.Parameter1String = model?.Parameter1String;
            viewModel.Parameter2Int = model?.Parameter2Int;
            viewModel.IdOfTargetInputField = model?.IdOfTargetInputField;

            {
                //this should come from DB in real application
                SelectList list1 = new SelectList
                (new List<SelectListItem>
                {
                            new SelectListItem
                            {
                                Text = "John - 111",
                                Value = "111"
                            },

                           new SelectListItem
                            {
                                Text = "Mark - 222",
                                Value = "222"
                            },
                            new SelectListItem
                            {
                                Text = "Novak - 333",
                                Value = "333"
                            },
                }, "Value", "Text");

                viewModel.ListOfCustomers = list1;
            }

            //now render component view
            return View("Default", viewModel);
        }
    }
}

//CustomersSearchViewComponent.cs===================================================
namespace Example1.ViewComponents
{    
    //it should inherit from ViewComponent class
    //this is Async version of ViewComponent
    public class CustomersSearchViewComponent : ViewComponent
    {
        private readonly ILogger<CustomersSearchViewComponent>? _logger;

        // We are testing Dependency Injection (DI) to inject the logger
        public CustomersSearchViewComponent(ILogger<CustomersSearchViewComponent> logger)
        {
            //note how useful is this, we are passing in this constructor
            //into view-component objects that are part of app DI container
            //you can pass here any form of reference to DataBase, for example
            //like EF DbContext or similar
            _logger = logger;
        }

        //The Invoke method for the View component
        public async Task<IViewComponentResult> InvokeAsync(
            CustomersSearchViewComponent_Model? model=null
        )
        {
            //this is neat, this parameters in CustomersSearchViewComponent_Model
            //are passed to the view-component from host form
            {
                //testing that DI worked - logger 
                string methodName = $"Type: {System.Reflection.MethodBase.GetCurrentMethod()?.DeclaringType?.FullName}, " +
                    $"Method: InvokeAsync; ";
                _logger?.LogWarning(methodName);
            }

            await Task.Delay(0); // Simulate some async work

            //preparing view-model
            CustomersSearchViewComponent_ViewModel viewModel = new();
            viewModel.Parameter1String = model?.Parameter1String;
            viewModel.Parameter2Int = model?.Parameter2Int;
            viewModel.IdOfTargetInputField = model?.IdOfTargetInputField;

            {
                //this should come from DB in real application
                SelectList list1 = new SelectList
                (new List<SelectListItem>
                {
                            new SelectListItem
                            {
                                Text = "John - 111",
                                Value = "111"
                            },

                           new SelectListItem
                            {
                                Text = "Mark - 222",
                                Value = "222"
                            },
                            new SelectListItem
                            {
                                Text = "Novak - 333",
                                Value = "333"
                            },
                }, "Value", "Text");

                viewModel.ListOfCustomers= list1;
            }

            //now render component view
            return View("Default",viewModel);
        }
    }
} 

3.2 Razor (.cshtml) part

Test1.cshtml
This Razor view demonstrates how to use two View Components—one asynchronous and one synchronous—by invoking them both with the Component.InvokeAsync method and with tag helpers. The page includes a simple form for entering a customer ID and displays the results of each View Component in separate sections, showing different ways to pass parameters and render reusable UI blocks in a Razor Pages project. The layout uses Bootstrap for styling and organization.

CustomersSearch\Default.cshtml
This Razor view is the UI for the CustomersSearch asynchronous View Component. It displays parameters passed from the host form, shows a dropdown list of customers, and includes a button that, when clicked, copies the selected customer ID from the dropdown into a specific input field on the main form using JavaScript. The layout uses Bootstrap classes for styling and organization.

CustomersSearch2\Default.cshtml
This Razor view defines the UI for the synchronous CustomersSearch2 View Component. It displays parameters received from the host form, shows a dropdown list of customers, and provides a button that copies the selected customer ID from the dropdown into a specific input field on the main form using JavaScript. The component uses Bootstrap classes for layout and visual styling.


 <!--Test1.cshtml  --------------------------------------------------->
@using Example1.Models.Home;
@using Example1.ViewComponents;
@using Example1.ViewComponents.Models;
@using Example1
@using Example1.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, Example1
@addTagHelper *, Example1.TagHelpers

@model Test1_ViewModel

@{
    ViewData["Title"] = "Test1";

    <!--Title  --------------------------------------------------->
    <h5 class="bg-primary text-left p-1 mt-1">
        <span class="d-inline-block ms-2">
            @ViewData["Title"]
        </span>
    </h5>

    <!-- Flexbox row1 -->
    <div class="d-flex" style="width:1100px">
        <!-- Div 0-------------------------------------------------->
        <div class="m-3 p-3">
            <fieldset class="border rounded-3 p-3 bg-light shadow" style="width:500px">
                <legend class="float-none w-auto px-3 border bg-light rounded-3 ">
                    Div - 0
                </legend>
                <!-- Form ----------------------------------------------------------------- -->
                <form id="form1" method="post" class="row">
                    <div class="form-group">
                        <label asp-for="ContractOwnerCustomer">
                            Customer Id
                        </label>
                        <input id="ContractOwnerCustomerId" class="form-control" asp-for="ContractOwnerCustomer" />
                    </div>
                </form>

                <hr />
                <!--Buttons ----------------------------------------------------- -->
                <div>
                    <button type="submit" form="form1" class="btn btn-primary mt-3 me-2 float-end"
                            href=''>
                        Submit
                    </button>
                </div>
            </fieldset>
        </div>
    </div>
    <!-- Flexbox row2 -->
    <div class="d-flex" style="width:1100px">
        <!-- Div 1----------------------------------------------- -->
        <div class="m-3 p-3">
            <h5 class="bg-secondary text-center p-1">
                Div1 - CustomersSearch, Async, invoked with method
            </h5>
            @{
                CustomersSearchViewComponent_Model myModel1 = new CustomersSearchViewComponent_Model();
                myModel1.Parameter1String = "Div1-Async, invoked with method";
                myModel1.Parameter2Int = 11111;
                myModel1.IdOfTargetInputField = "ContractOwnerCustomerId";

                //so, here we are using the ViewComponent
                @await Component.InvokeAsync("CustomersSearch", new
                    {
                        model = myModel1,
                    })  ;
            }
        </div>
        <!-- Div 2----------------------------------------------- -->
        <div class="m-3 p-3">
            <h5 class="bg-secondary text-center p-1">
                Div2 - CustomersSearch, Async, invoked with Tag Helper
            </h5>
            @{
                CustomersSearchViewComponent_Model myModel2 = new CustomersSearchViewComponent_Model();
                myModel2.Parameter1String = "Div2-Async, invoked with Tag Helper";
                myModel2.Parameter2Int = 22222;
                myModel2.IdOfTargetInputField = "ContractOwnerCustomerId";
            }

            <vc:customers-search model=myModel2>
            </vc:customers-search>
        </div>
    </div>
    <!-- Flexbox  row3-->
    <div class="d-flex" style="width:1100px">
        <!-- Div 3----------------------------------------------- -->
        <div class="m-3 p-3">
            <h5 class="bg-secondary text-center p-1">
                Div3 - CustomersSearch2, Sync, invoked with method
            </h5>
            @{
                CustomersSearchViewComponent_Model myModel3 = new CustomersSearchViewComponent_Model();
                myModel3.Parameter1String = "Div3-Sync, invoked with method";
                myModel3.Parameter2Int = 33333;
                myModel3.IdOfTargetInputField = "ContractOwnerCustomerId";

                //so, here we are using the ViewComponent
                //it looks strange, Sync component invoked with async method
                //but they want it this way
                @await Component.InvokeAsync("CustomersSearch2", new
                    {
                        model = myModel3,
                    })        ;
            }
        </div>
        <!-- Div 4----------------------------------------------- -->
        <div class="m-3 p-3">
            <h5 class="bg-secondary text-center p-1">
                Div4 - CustomersSearch2, Sync, invoked with Tag Helper
            </h5>
            @{
                CustomersSearchViewComponent_Model myModel4 = new CustomersSearchViewComponent_Model();
                myModel4.Parameter1String = "Div4- Sync, invoked with Tag Helper";
                myModel4.Parameter2Int = 44444;
                myModel4.IdOfTargetInputField = "ContractOwnerCustomerId";
            }

            <vc:customers-search2 model=myModel4>
            </vc:customers-search2>
        </div>
        <!--  ----------------------------------------------- -->
    </div>
}

<!--CustomersSearch\Default.cshtml  -------------------------------->
@using Example1.ViewComponents.Models

@model CustomersSearchViewComponent_ViewModel

<!--Visually, this div (color bg-info) is the component--->
<div id="ViewComponents1" class="bg-info p-3" style="width:400px">
    <!--Title  --------------------------------------------------->
    <h5 class="bg-primary text-center p-1 m-3">
        CustomersSearch - Async ViewComponent
    </h5>

    <p>
        Proof-of-concept, parameter from Host Form 
        <br/> Parameter1String: @Model.Parameter1String
    </p>
    <p>
        Proof-of-concept, parameter from Host Form
        <br /> Parameter2Int: @Model.Parameter2Int
    </p>

    <fieldset class="border rounded-3 p-3 m-2 shadow">
        <label>
            Proof-of-concept, list of customers from DB
        </label>
        <select id="customerSelect2" class="form-select"
                asp-items="@Model.ListOfCustomers">
        </select>

        <a class="btn btn-secondary mt-3 float-end"
           onclick="copyInputTextTo(this)">
            Copy Selected Customer ID to Form
        </a>
    </fieldset>
</div> <!-- End of <div id="ViewComponents1"  -->

<script>
    // This function will export selection value from the ViewComponent
    // to the main form and copy it to the input field
    function copyInputTextTo(anchor) {
        var root = anchor.closest('fieldset');
        var target = document.getElementById("@Model.IdOfTargetInputField");
        var source = root.querySelector('#customerSelect2') ;

        if (source && target) {
            target.value = source.value;
        }
        return false;
    }
</script>

<!--CustomersSearch2\Default.cshtml  -------------------------------->
@using Example1.ViewComponents.Models

@model CustomersSearchViewComponent_ViewModel

<!--Visually, this div (color bg-warning) is the component--->
<div id="ViewComponents1" class="bg-warning p-3" style="width:400px">
    <!--Title  --------------------------------------------------->
    <h5 class="bg-primary text-center p-1 m-3">
        CustomersSearch2 - Sync ViewComponent
    </h5>

    <p>
        Proof-of-concept, parameter from Host Form
        <br /> Parameter1String: @Model.Parameter1String
    </p>
    <p>
        Proof-of-concept, parameter from Host Form
        <br /> Parameter2Int: @Model.Parameter2Int
    </p>

    <fieldset class="border rounded-3 p-3 m-2 shadow">
        <label>
            Proof-of-concept, list of customers from DB
        </label>
        <select id="customerSelect2" class="form-select"
                asp-items="@Model.ListOfCustomers">
        </select>

        <a class="btn btn-secondary mt-3 float-end"
           onclick="copyInputTextTo(this)">
            Copy Selected Customer ID to Form
        </a>
    </fieldset>
</div> <!-- End of <div id="ViewComponents1"  -->

<script>
    // This function will export selection value from the ViewComponent
    // to the main form and copy it to the input field
    function copyInputTextTo(anchor) {
        var root = anchor.closest('fieldset');
        var target = document.getElementById("@Model.IdOfTargetInputField");
        var source = root.querySelector('#customerSelect2') ;

        if (source && target) {
            target.value = source.value;
        }
        return false;
    }
</script>

3.3 Location of files in the project

The runtime searches for the view in the following paths:

/Views/{Controller Name}/Components/{View Component Name}/{View Name}
/Views/Shared/Components/{View Component Name}/{View Name}
/Pages/Shared/Components/{View Component Name}/{View Name}
/Areas/{Area Name}/Views/Shared/Components/{View Component Name}/{View Name}

4 Conclusion

View Components in ASP.NET8 is are higher form of component architecture, and I found it practical. It takes a while to get used to them and learn how to create/use them, but they are definitely useful to any experienced ASP.NET programmer.

5 References

[1] View components in ASP.NET Core
https://learn.microsoft.com/en-us/aspnet/core/mvc/views/view-components?view=aspnetcore-9.0

[2] View Components in ASP.NET Core MVC
https://dotnettutorials.net/lesson/view-components-in-asp-net-core-mvc/

ASP.NET8 – Storing an Object in Session

ASP.NET8 – Storing an Object in Session

Here we show how to solve the problem of saving C# objects in ASP.NET8 Session.

Abstract: Storing C# objects in ASP.NET8 Session is supported by the framework; only strings, presumably JSON, can be stored in the Session. We show here code that extends ASP.NET8 framework and adds that functionality.

1 Saving C# objects not supported by ASP.NET8 Session

A quick look at ISession interface [2] of ASP.NET8 framework will tell us immediately that saving objects is not supported.

//Assembly Microsoft.AspNetCore.Http.Features, Version=8.0.0.0
namespace Microsoft.AspNetCore.Http
{
    // The session data is backed by a cache and considered ephemeral data.
    public interface ISession
    {
        bool IsAvailable { get; }        
        string Id { get; }        
        IEnumerable<string> Keys { get; }
        void Clear();       
        Task CommitAsync(CancellationToken cancellationToken = default);        
        Task LoadAsync(CancellationToken cancellationToken = default);        
        void Remove(string key);       
        void Set(string key, byte[] value);        
        bool TryGetValue(string key, [NotNullWhen(true)] out byte[]? value);
    }
}

What they envisioned is that the developer will save only serialized JSON objects.

1.1 Why is that a problem

There are 2 reasons why that is a problem:

  1. Migration of old code from ASP.NET from .NET4.8 is unnecessarily difficult, since that framework supports saving objects into the Session
  2. If you have a case of frequent writes into the session object, the process of serializing/deserializing into/from JSON consumes resources and complicates code

1.2 Sample Use Case

My use case was that I would create C# object ProgressLevel object in which I would keep the current state of completion, start long background processing, and have an AJAX method every 2 seconds read the completion state and show it to the user in the browser. That C# object ProgressLevel has 100+ writes during processing, so I did not want to deserialize it into/from JSON 100+ times. It needs to be serialized into JSON only every 2 seconds.

2 Solution is to extend ASP.NET Session functionality

I found an excellent solution to the problem in article [1] by the author Kalpesh. My solution/code here is a modification of that code adopted for my environment.

The idea is simple. Since Session itself is Cache based, why not extend it with ability to add C# objects to it?

System.Runtime.Caching.MemoryCache is the standard implementation of an in-memory cache for C# applications. So, let’s use System.Runtime.Caching.MemoryCache for that.

We are intentionally adding mutable objects to the Cache, but we have full control of them. They are not intended to be used as Cache, but as an objects store. Be careful about potential problems as described in [5].

Based on [4], System.Runtime.Caching.MemoryCache is thread-safe.

3 Code of Session extension class

This file defines a static class SessionExtensions that provides extension methods for working with session-based object storage in ASP.NET Core. The goal is to allow storing and retrieving arbitrary objects in the session, using an in-memory cache (MemoryCache) as the backing store.
Key points:
MemoryCache Usage:
A static MemoryCache instance is used to store session data. Each session gets a unique cache key based on the session ID and a constant string.
Object Store:
For each session, a Dictionary<string, object> acts as a key-value store for objects. This dictionary is stored in the cache.
Initialization:
• InitObjectStore creates a new object store for a session, with optional sliding expiration.
• InitObjectStoreIfNeeded checks if the store exists for the session and initializes it if not.
CRUD Operations:
• SetObject adds or updates an object in the session store.
• GetObject retrieves an object by key.
• RemoveObject removes an object by key.
• RemoveObjectStore clears and removes the entire object store for a session.
• GetAllObjectsKeyValuePair returns the entire dictionary for a session.
Thread Safety:
MemoryCache is thread-safe, so concurrent access is safe.
Usage:
These methods are intended to be called as extension methods on ISession objects, making it easy to manage complex session data in ASP.NET Core applications.

public static class SessionExtensions
{
    //Mark Pelf, May 2025
    //code inspired with solution from
    //http://blogs.sarasennovations.com/2020/01/storing-objects-in-session-in-aspnet.html  
    //adopted to my needs
    private static ObjectCache _Cache;

    //we just need some unique string to identify the session cache
    private const string SESSION_CACHE_KEY = "_MyAppUniqueString_";

    static SessionExtensions()
    {
        //MemoryCache is the standard implementation of an in-memory cache for C# applications.
        //We are intentionally adding mutable objects to the Cache, but we have full control of them
        //System.Runtime.Caching.MemoryCache is thread safe
        //------------------------------
        //parameter "sessionCache" to constructor is the name to use to look up
        //configuration information.
        //---------------------------------
        //If a matching configuration entry exists, the configuration
        //information is used to configure the MemoryCache instance. 
        _Cache = new System.Runtime.Caching.MemoryCache("sessionCache");
    }

    private static Dictionary<string, object>? GetPrivateObjectStore(string cacheKey)
    {
        CacheItem cacheItemForCacheKey = _Cache.GetCacheItem(cacheKey);

        if (cacheItemForCacheKey != null)
        {
            return (Dictionary<string, object>)cacheItemForCacheKey.Value;
        }

        return null;
    }

    //this constructor will create a new object store
    //it will overwrite any existing object store with same cacheKeyForThisSession
    //it can NOT be called multiple times, because it will overwrite the previous store
    //that is why I made it private
    private static void InitObjectStore(this ISession session, double SessionTimeoutMin=0)
    {
        CacheItemPolicy cacheItemPolicyForThisSession = new CacheItemPolicy();

        //decide if user wants to set a timeout(sliding expiration) for the 
        //objects he/she is storing in the session
        if (SessionTimeoutMin > 0)
        {
            //set sliding timer to session time + 1, so that cache do not expire before session does
            //objects will be removed if not used after timeout
            cacheItemPolicyForThisSession.SlidingExpiration = 
                TimeSpan.FromMinutes(SessionTimeoutMin + 1);
        }
        else
        {
            //Keep objects until manually removed
            cacheItemPolicyForThisSession.SlidingExpiration = 
                ObjectCache.NoSlidingExpiration;
        }

        string cacheKeyForThisSession = session.Id + SESSION_CACHE_KEY;
        CacheItem cacheItemForThisSession = new CacheItem(cacheKeyForThisSession);
        Dictionary<string, object> sessionObjectsStore = new Dictionary<string, object>();
        cacheItemForThisSession.Value = sessionObjectsStore;

        _Cache.Set(cacheItemForThisSession, cacheItemPolicyForThisSession);
    }

    //this constructor will check if the object store is already initialized
    //if not, it will initialize it
    //it can be called multiple times, no harm done
    //I add this method to ASP.NET MVC Controller constructor
    public static void InitObjectStoreIfNeeded(this ISession session, double SessionTimeoutMin = 0)
    {
        string cacheKeyForThisSession = session.Id + SESSION_CACHE_KEY;

        CacheItem cacheItemForThisSession = _Cache.GetCacheItem(cacheKeyForThisSession);

        if (cacheItemForThisSession == null)
        {
            InitObjectStore(session, SessionTimeoutMin);
        }
    }

    public static void RemoveObjectStore(this ISession session)
    {
        string cacheKeyForThisSession = session.Id + SESSION_CACHE_KEY;

        Dictionary<string, object>? sessionObjectsStore = GetPrivateObjectStore(cacheKeyForThisSession);

        if(sessionObjectsStore != null)
        {
            sessionObjectsStore.Clear();

            //also remove the collection from cache
            _Cache.Remove(cacheKeyForThisSession);
        }           
    }

    public static Dictionary<string, object>? GetAllObjectsKeyValuePair(this ISession session)
    {
        Dictionary<string, object>? sessionObjectsStore = 
            GetPrivateObjectStore(session.Id + SESSION_CACHE_KEY);

        return sessionObjectsStore;
    }

    public static void RemoveObject(this ISession session, string key_StringIdOfObject)
    {
        string cacheKeyForThisSession = session.Id + SESSION_CACHE_KEY;

        Dictionary<string, object>? sessionObjectsStore = 
            GetPrivateObjectStore(cacheKeyForThisSession);

        if (sessionObjectsStore != null)
        {
            sessionObjectsStore.Remove(key_StringIdOfObject);
        }
    }

    public static void SetObject(this ISession session, 
        string key_StringIdOfObject, object? value_ReferenceToObject)
    {
        InitObjectStoreIfNeeded(session);

        Dictionary<string, object>?  sessionObjectsStore = 
            GetPrivateObjectStore(session.Id + SESSION_CACHE_KEY);

        if (sessionObjectsStore == null) 
            throw new Exception("Object store is null after InitObjectStore call.");

        if (value_ReferenceToObject == null )
        {
            sessionObjectsStore.Remove(key_StringIdOfObject);
        }
        else
        {
            sessionObjectsStore[key_StringIdOfObject] = value_ReferenceToObject;
        }
    }

    public static object? GetObject(this ISession session, string key_StringIdOfObject)
    {
        string cacheKeyForThisSession = session.Id + SESSION_CACHE_KEY;

        Dictionary<string, object>? sessionObjectsStore = 
            GetPrivateObjectStore(cacheKeyForThisSession);

        object? result_ReferenceToObject = null;

        if (sessionObjectsStore != null)
        {
            result_ReferenceToObject = sessionObjectsStore[key_StringIdOfObject];
        }

        return result_ReferenceToObject;
    }
}

3.1 Usage

Here are a few examples of how I used the code above. It is well commented, and it should be self-explanatory.

//I added this to my ASP.NET8 MVC base controller class
//you can add this to your ASP.NET8 MVC Controller constructor
//it can be called multiple times, no harm done

{
    //Initialize ObjectStore (session extension functions)
    var CurrentSession = this.HttpContext?.Session;
    if (CurrentSession != null)
    {
        CurrentSession.InitObjectStoreIfNeeded();
    }
}

//==========================================================
//This is how I save object into the Session

const string ProgressLevel_ReleaseContract = "ProgressLevel_ReleaseContract";
ProgressLevel? Output1 = null;
Output1 = new ProgressLevel();

{
    ISession? CurrentSession = this.HttpContext?.Session;
    if (CurrentSession != null)
    {
     CurrentSession.SetObject(ProgressLevel_ReleaseContract, Output1);
    }
}
//==========================================================
//Usage of object from Session 
//Reference to C# object ProgressLevel? Output1 is passed as a parameter to
//different methods that do background processing and in that object they report
//on work/changes/progress done
//In my case, processing is "per ASO.NET session" and is quite serialized, 
//and since C# object ProgressLevel? Output1 is unique per session, I do not have
//much parallelism/concurrency issues.
//Actually, the problem is when background thread is doing some processing ans writing
//into ProgressLevel object, and UI is doing AJAX and reading and JSON serializing 
//same object, that is concurrency case, but works fine in my case.

//==========================================================
//This is how I get object from Session in my AJAX action
//and serialize it into JSon  

 public IActionResult ContractReleaseExecution_ProgressStatus_AJAX(string? notUsed=null)
 {
     string? jsonProgressLevel = null;
     try
     {
         ISession? CurrentSession = this.HttpContext?.Session;
         if (CurrentSession != null)
         {
             object? obj1 = CurrentSession.GetObject(ProgressLevel_ReleaseContract);
             ProgressLevel? progressLevel = obj1 as ProgressLevel;

             if (progressLevel != null)
             {
                 jsonProgressLevel = JsonConvert.SerializeObject(progressLevel);
             }
         }
     }
     catch (Exception ex)
     {
         string methodName = $"Type: {System.Reflection.MethodBase.GetCurrentMethod()?.DeclaringType?.FullName}, " +
             $"Method: ContractReleaseExecution_ProgressStatus_AJAX; ";
         Log.Error(methodName, ex);
         jsonProgressLevel = null;
     }

     return Json(jsonProgressLevel);
 }
 //==============================================

4 Conclusion

This solution to extend ASP.NET8 Session with the ability to save C# objects was very practical and useful to me. I think ASP.NET Core framework creators were a bit detached from reality and practical programming with their insisting/limitations of Session objects being only serialized JSON strings. In the past, there was a lot of fascination with XML format, then now with JSON for serialization. All these formats are useful in certain contexts, but do not cover all the use cases. Forcing restrictions to just any of those formats is a serious limitation.

5 References

[1] Storing Objects in In-Memory Session in Asp.Net Core
http://blogs.sarasennovations.com/2020/01/storing-objects-in-session-in-aspnet.html

[2] ISession Interface
https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.isession?view=aspnetcore-9.0

[3] MemoryCache in C#: A Practical Guide
https://blog.postsharp.net/memorycache

[4] MemoryCache Class
https://learn.microsoft.com/en-us/dotnet/api/system.runtime.caching.memorycache?view=net-9.0-pp

[5] My own pitfalls with .NET runtime cache
https://www.enkelmedia.se/blogg/2021/10/13/my-own-pitfalls-with-net-runtime-cache

ASP.NET8 – Using Bootstrap 5 Themes

ASP.NET8 – Using Bootstrap 5 Themes

Using multiple Bootstrap 5 Themes in ASP.NET8.

Abstract: We show how to configure the usage of multiple Bootstrap 5 Themes in ASP.NET8 MVC application. Many free Bootstrap 5 themes are available on the internet and can make web apps look better.

1 The need for a more professional UI look

ASP.NET8 MVC project is set by default with Bootstrap 5 CSS UI framework. But, somehow, the default Bootstrap 5 color palette does not look that good for the Bank application I was working on. So, I needed to look for some better Bootstrap 5 Themes.

1.1 Free Bootstrap 5 themes

There are many free Bootstrap themes on the Internet. I liked the site Bootswatch [1]. Themes there are FREE, with MIT license. I decided to use those free themes for my project.

I realized, theoretically, I can make even the admin choose the theme for the app, from several themes I offer preinstalled. So, I decided to make a proof-of-concept application.

2 Installing multiple Bootstrap 5 Themes

2.1 Configuration in appsetting.json

I decided to enable the web application admin to select the theme he/she prefers, by the configuration option in appsettings.json.

2.2 Installing multiple themes

I downloaded several themes I liked and installed in the app.

2.3 Passing theme number to the Partial view

I defined my model and passed the info to _Layout.cshtml.

From there I passed the info to the partial view _LoadingLayoutJsAndCss.cshtml.

In that partial view _LoadingLayoutJsAndCss.cshtml, I decide which Bootstrap theme to load, based on the configured number in appsettings.json.

3 Final result

I will just show here what some themes look like in my prototype ASP.NET8 app. Please note that Theme 0 is the default Bootstrap 5 theme. It is pretty impressive how a good theme can change app's look.

The problem is, that Bootstrap Theme sometimes changes not just colors, but border thickness, button shape (rounded, square, etc), spacing between elements etc. Theoretically, you need to test every dialog/form for each Bootstrap Theme you offer to users.

4 Concerns and Conclusion

The problem is that each theme not only changes colors but also button sizes and spacing, so theoretically one would need to test “every form for every theme” to make sure it renders properly, before delivering the product.

I needed to do some work on CSS to support Themes. I needed to make sure components CSS like Breadcrumb and DataTables inherit colors from the theme. The problem is if they have color definitions in their own CSS, they will not follow the theme. You might need to do some work there.

If the project team/QA-team wants to focus on only one theme, you can HARDCODE theme number in the release build and disable that config option.

Just, I think developers-team would need to do development in the Bootstrap default theme (0), to make sure they do not fall for some bug in the theme itself. Those themes themselves can have bugs/issues.

Still, it is amazing how many quality free resources one can find for Bootstrap UI framework.

6 References

[1] https://bootswatch.com/

ASP.NET 8 – Multilingual Application with single Resx file – Part4 – Resource Manager

ASP.NET 8 – Multilingual Application with single Resx file – Part4 – Resource Manager

A practical guide to building a multi-language Asp.Net 8 MVC application.

Abstract:. A practical guide to building a multi-language Asp.Net 8 MVC application where all language resource strings are kept in a single shared file, as opposed to having separate resource files for each controller/view. Here we show that old fashion approach of the Resource Manager is still working in Asp.Net 8 MVC.

1 Resource Manager is still working in Asp.Net 8 MVC

For those who like the old-fashioned approach, the good news is that the Resource Manager is still working in Asp.Net 8 MVC. You can use it together at the same time as IStringLocalizer, or even as the only localization mechanism if that is what you like.

1.1 How Resource Manager works

So, a typical solution is to use expressions in code like “Resources.SharedResource.Wellcome”. That is really a property that evaluates to the string. Evaluation to the string is done dynamically in run-time, and the string is chosen from SharedResource resx files, based on the current thread culture.

2 Other articles in this series

Articles in this series are:
ASP.NET 8 – Multilingual Application with single Resx file – Part 1
ASP.NET 8 – Multilingual Application with single Resx file – Part 2 – Alternative Approach
ASP.NET 8 – Multilingual Application with single Resx file – Part 3 – Form Validation Strings
ASP.NET 8 – Multilingual Application with single Resx file – Part 4 – Resource Manager

3 Shared Resources approach

By default, Asp.Net Core 8 MVC technology envisions separate resource file .resx for each controller and the view. But most people do not like it, since most multilanguage strings are the same in different places in the application, we would like it to be all in the same place.
Literature [1] calls that approach the “Shared Resources” approach. In order to implement it, we will create a marker class SharedResoureces.cs to group all the resources.

4 Steps to Multilingual Application

4.1 Configuring Localization Services and Middleware

Localization services are configured in Program.cs:

 private static void AddingMultiLanguageSupportServices(WebApplicationBuilder? builder)
 {
     if (builder == null) { throw new Exception("builder==null"); };

     builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
     builder.Services.AddMvc()
             .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix);
     builder.Services.Configure<RequestLocalizationOptions>(options =>
     {
         var supportedCultures = new[] { "en", "fr", "de", "it" };
         options.SetDefaultCulture(supportedCultures[0])
             .AddSupportedCultures(supportedCultures)
             .AddSupportedUICultures(supportedCultures);
     });
 }

 private static void AddingMultiLanguageSupport(WebApplication? app)
 {
     app?.UseRequestLocalization();
 }

4.2 Create marker class SharedResources.cs

This is just a dummy marker class to group shared resources. We need it for its name and type.
It seems the namespace needs to be the same as the app root namespace, which needs to be the same as the assembly name. I had some problems when changing the namespace, it would not work. If it doesn't work for you, you can try to use the full class name in your DI instruction, like this one:
IStringLocalizer<SharedResources01.SharedResource> StringLocalizer

There is no magic in the name "SharedResource", you can name it "MyResources" and change all references in the code to "MyResources" and all will still work.

The location seems can be any folder, although some articles ([6] claim it needs to be the root project folder I do not see such problems in this example. To me looks like it can be any folder, just keep your namespace tidy.


//SharedResource.cs===================================================
namespace SharedResources04
{
    /*
    * This is just a dummy marker class to group shared resources
    * We need it for its name and type
    * 
    * It seems the namespace needs to be the same as app root namespace
    * which needs to be the same as the assembly name.
    * I had some problems when changing the namespace, it would not work.
    * If it doesn't work for you, you can try to use full class name
    * in your DI instruction, like this one:
    * IStringLocalizer<SharedResources04.SharedResource> StringLocalizer
    * 
    * There is no magic in the name "SharedResource", you can
    * name it "MyResources" and change all references in the code
    * to "MyResources" and all will still work
    * 
    * Location seems can be any folder, although some
    * articles claim it needs to be the root project folder
    * I do not see such problems in this example. 
    * To me looks it can be any folder, just keep your
    * namespace tidy. 
    */

    public class SharedResource
    {
    }
}

4.3 Create language resources files

In the folder “Resources” create your language resources files, and make sure you name them SharedResources.xx.resx.

In Visual Studio Resource editor you need to set Access Modifier to Public for all resource files.

4.4 Selecting Language/Culture

Based on [5], the Localization service has three default providers:

  1. QueryStringRequestCultureProvider
  2. CookieRequestCultureProvider
  3. AcceptLanguageHeaderRequestCultureProvider

Since most apps will often provide a mechanism to set the culture with the ASP.NET Core culture cookie, we will focus only on that approach in our example.
This is the code to set .AspNetCore.Culture cookie:

private void ChangeLanguage_SetCookie(HttpContext myContext, string? culture)
{
    if(culture == null) { throw new Exception("culture == null"); };

    //this code sets .AspNetCore.Culture cookie
    CookieOptions cookieOptions = new CookieOptions();
    cookieOptions.Expires = DateTimeOffset.UtcNow.AddMonths(1);
    cookieOptions.IsEssential = true;

    myContext.Response.Cookies.Append(
        CookieRequestCultureProvider.DefaultCookieName,
        CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
        cookieOptions
    );
}

Cookie can be easily seen with Chrome DevTools:

I built a small application to demo it, and here is the screen where I can change the language:

Note that I added some debugging info into the footer, to show the value of the Request language cookie, to see if the app is working as desired.

4.5 Using Localization Services in the Controller

Here we show how both approaches, IStringLocalizer and Resource Manager are used to localize strings in the Controller code
Here is the code snippet:

 public class HomeController : Controller
 {
     private readonly ILogger<HomeController> _logger;
     private readonly IStringLocalizer<SharedResource> _stringLocalizer;

     /* Here is, of course, the Dependency Injection (DI) coming in and filling 
      * all the dependencies. The key thing is we are asking for a specific 
      * type=SharedResource. 
      * If it doesn't work for you, you can try to use full class name
      * in your DI instruction, like this one:
      * IStringLocalizer<SharedResources04.SharedResource> stringLocalizer
      */
     public HomeController(ILogger<HomeController> logger,
         IStringLocalizer<SharedResource> stringLocalizer)
     {
         _logger = logger;
         _stringLocalizer = stringLocalizer;
     }

     //============================
     public IActionResult LocalizationExample(LocalizationExampleViewModel model)
{
    string text = "Thread CurrentUICulture is [" + @Thread.CurrentThread.CurrentUICulture.ToString() + "] ; ";
    text += "Thread CurrentCulture is [" + @Thread.CurrentThread.CurrentCulture.ToString() + "]";
    model.ThreadCultureInController = text;
    //here we test localization by Resource Manager
    model.LocalizedInControllerByResourceManager1 = Resources.SharedResource.Wellcome;
    model.LocalizedInControllerByResourceManager2 = Resources.SharedResource.Hello_World;
    //here we test localization by IStringLocalizer
    model.LocalizedInControllerByIStringLocalizer1 = _stringLocalizer["Wellcome"];
    model.LocalizedInControllerByIStringLocalizer2 = _stringLocalizer["Hello World"];

    return View(model);
}

4.6 Using Localization Services in the View

Here we show how both approaches, IStringLocalizer and Resource Manager are used to localize strings in the View code
Here is the code snippet:

@* LocalizationExample.cshtml ====================================================*@
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Localization
@using SharedResources04

@model LocalizationExampleViewModel

@* Here is of course the Dependency Injection (DI) coming in and filling
all the dependencies. The key thing is we are asking for a specific
type=SharedResource.
If it doesn't work for you, you can try to use full class name
in your DI instruction, like this one:
@inject IStringLocalizer<SharedResources01.SharedResource> StringLocalizer
 *@

@inject IStringLocalizer<SharedResource> StringLocalizer

@{
    <div style="width:600px">
        <p class="text-success">
            Controller Thread Culture:  <br />
            @Model.ThreadCultureInController
        </p>

        <p class="text-primary">
            Localized In Controller By ResourceManager: <br />
            @Model.LocalizedInControllerByResourceManager1
        </p>

        <p class="text-primary">
            Localized In Controller By ResourceManager: <br />
            @Model.LocalizedInControllerByResourceManager2
        </p>

        <p class="text-primary">
            Localized In Controller By IStringLocalizer: <br />
            @Model.LocalizedInControllerByIStringLocalizer1
        </p>

        <p class="text-primary">
            Localized In Controller By IStringLocalizer: <br />
            @Model.LocalizedInControllerByIStringLocalizer2
        </p>

        <p class="text-success">
            @{
                string text = "Thread CurrentUICulture is [" +
                @Thread.CurrentThread.CurrentUICulture.ToString() + "] ; ";
                text += "Thread CurrentCulture is [" +
                @Thread.CurrentThread.CurrentCulture.ToString() + "]";
            }
            View Thread Culture:  <br />
            @text
        </p>

        <p class="text-primary">
            Localized In View By ResourceManager: <br />
            @SharedResources04.Resources.SharedResource.Wellcome
        </p>

        <p class="text-primary">
            Localized In View By ResourceManager: <br />
            @SharedResources04.Resources.SharedResource.Hello_World
        </p>

        <p class="text-primary">
            Localized In View By IStringLocalizer: <br />
            @StringLocalizer["Wellcome"]
        </p>

        <p class="text-primary">
            Localized In View By IStringLocalizer: <br />
            @StringLocalizer["Hello World"]
        </p>

    </div>
}

4.7 Execution result

Here is what the execution result looks like:

Note that I added some debugging info into the footer, to show the value of the Request language cookie, to see if the app is working as desired.

5 Full Code

Since most people like code they can copy-paste, here is the full code of the application. Code can be downladed at GitHub [99].

//Program.cs===========================================================================
namespace SharedResources04
{
    public class Program
    {
        public static void Main(string[] args)
        {
            //=====Middleware and Services=============================================
            var builder = WebApplication.CreateBuilder(args);

            //adding multi-language support
            AddingMultiLanguageSupportServices(builder);

            // Add services to the container.
            builder.Services.AddControllersWithViews();

            //====App===================================================================
            var app = builder.Build();

            //adding multi-language support
            AddingMultiLanguageSupport(app);

            // Configure the HTTP request pipeline.
            if (!app.Environment.IsDevelopment())
            {
                app.UseExceptionHandler("/Home/Error");
            }
            app.UseStaticFiles();

            app.UseRouting();

            app.UseAuthorization();

            app.MapControllerRoute(
                name: "default",
                pattern: "{controller=Home}/{action=ChangeLanguage}/{id?}");

            app.Run();
        }

        private static void AddingMultiLanguageSupportServices(WebApplicationBuilder? builder)
        {
            if (builder == null) { throw new Exception("builder==null"); };

            builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
            builder.Services.AddMvc()
                    .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix);
            builder.Services.Configure<RequestLocalizationOptions>(options =>
            {
                var supportedCultures = new[] { "en", "fr", "de", "it" };
                options.SetDefaultCulture(supportedCultures[0])
                    .AddSupportedCultures(supportedCultures)
                    .AddSupportedUICultures(supportedCultures);
            });
        }

        private static void AddingMultiLanguageSupport(WebApplication? app)
        {
            app?.UseRequestLocalization();
        }
    }
}

//SharedResource.cs===================================================
namespace SharedResources04
{
    /*
    * This is just a dummy marker class to group shared resources
    * We need it for its name and type
    * 
    * It seems the namespace needs to be the same as app root namespace
    * which needs to be the same as the assembly name.
    * I had some problems when changing the namespace, it would not work.
    * If it doesn't work for you, you can try to use full class name
    * in your DI instruction, like this one:
    * IStringLocalizer<SharedResources04.SharedResource> StringLocalizer
    * 
    * There is no magic in the name "SharedResource", you can
    * name it "MyResources" and change all references in the code
    * to "MyResources" and all will still work
    * 
    * Location seems can be any folder, although some
    * articles claim it needs to be the root project folder
    * I do not see such problems in this example. 
    * To me looks it can be any folder, just keep your
    * namespace tidy. 
    */

    public class SharedResource
    {
    }
}

//HomeController.cs================================================================
namespace SharedResources04.Controllers
{
    public class HomeController : Controller
    {
        private readonly ILogger<HomeController> _logger;
        private readonly IStringLocalizer<SharedResource> _stringLocalizer;

        /* Here is, of course, the Dependency Injection (DI) coming in and filling 
         * all the dependencies. The key thing is we are asking for a specific 
         * type=SharedResource. 
         * If it doesn't work for you, you can try to use full class name
         * in your DI instruction, like this one:
         * IStringLocalizer<SharedResources04.SharedResource> stringLocalizer
         */
        public HomeController(ILogger<HomeController> logger,
            IStringLocalizer<SharedResource> stringLocalizer)
        {
            _logger = logger;
            _stringLocalizer = stringLocalizer;
        }

        public IActionResult ChangeLanguage(ChangeLanguageViewModel model)
        {
            if (model.IsSubmit)
            {
                HttpContext myContext = this.HttpContext;
                ChangeLanguage_SetCookie(myContext, model.SelectedLanguage);
                //doing funny redirect to get new Request Cookie
                //for presentation
                return LocalRedirect("/Home/ChangeLanguage");
            }

            //prepare presentation
            ChangeLanguage_PreparePresentation(model);
            return View(model);
        }

        private void ChangeLanguage_PreparePresentation(ChangeLanguageViewModel model)
        {
            model.ListOfLanguages = new List<SelectListItem>
                        {
                            new SelectListItem
                            {
                                Text = "English",
                                Value = "en"
                            },

                            new SelectListItem
                            {
                                Text = "German",
                                Value = "de",
                            },

                            new SelectListItem
                            {
                                Text = "French",
                                Value = "fr"
                            },

                            new SelectListItem
                            {
                                Text = "Italian",
                                Value = "it"
                            }
                        };
        }

        private void ChangeLanguage_SetCookie(HttpContext myContext, string? culture)
        {
            if(culture == null) { throw new Exception("culture == null"); };

            //this code sets .AspNetCore.Culture cookie
            CookieOptions cookieOptions = new CookieOptions();
            cookieOptions.Expires = DateTimeOffset.UtcNow.AddMonths(1);
            cookieOptions.IsEssential = true;

            myContext.Response.Cookies.Append(
                CookieRequestCultureProvider.DefaultCookieName,
                CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
                cookieOptions
            );
        }

        public IActionResult LocalizationExample(LocalizationExampleViewModel model)
        {
            string text = "Thread CurrentUICulture is [" + @Thread.CurrentThread.CurrentUICulture.ToString() + "] ; ";
            text += "Thread CurrentCulture is [" + @Thread.CurrentThread.CurrentCulture.ToString() + "]";
            model.ThreadCultureInController = text;
            //here we test localization by Resource Manager
            model.LocalizedInControllerByResourceManager1 = Resources.SharedResource.Wellcome;
            model.LocalizedInControllerByResourceManager2 = Resources.SharedResource.Hello_World;
            //here we test localization by IStringLocalizer
            model.LocalizedInControllerByIStringLocalizer1 = _stringLocalizer["Wellcome"];
            model.LocalizedInControllerByIStringLocalizer2 = _stringLocalizer["Hello World"];

            return View(model);
        }

        public IActionResult Error()
        {
            return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
        }
    }
}

//ChangeLanguageViewModel.cs=====================================================
namespace SharedResources04.Models.Home
{
    public class ChangeLanguageViewModel
    {
        //model
        public string? SelectedLanguage { get; set; } = "en";

        public bool IsSubmit { get; set; } = false;

        //view model
        public List<SelectListItem>? ListOfLanguages { get; set; }
    }
}

//LocalizationExampleViewModel.cs===============================================
namespace SharedResources04.Models.Home
{
    public class LocalizationExampleViewModel
    {
        public string? LocalizedInControllerByResourceManager1 { get; set; }
        public string? LocalizedInControllerByResourceManager2 { get; set; }

        public string? LocalizedInControllerByIStringLocalizer1 { get; set; }
        public string? LocalizedInControllerByIStringLocalizer2 { get; set; }

        public string? ThreadCultureInController { get; set; }
    }
}
@* ChangeLanguage.cshtml ===================================================*@
@model ChangeLanguageViewModel

@{
    <div style="width:500px">
        <p class="bg-info">
            <partial name="_Debug.AspNetCore.CultureCookie" /><br />
        </p>

        <form id="form1">
            <fieldset class="border rounded-3 p-3">
                <legend class="float-none w-auto px-3">Change Language</legend>
                <div class="form-group">
                    <label asp-for="SelectedLanguage">Select Language</label>
                    <select class="form-select" asp-for="SelectedLanguage"
                            asp-items="@Model.ListOfLanguages">
                    </select>
                    <input type="hidden" name="IsSubmit" value="true">
                    <button type="submit" form="form1" class="btn btn-primary mt-3 float-end"
                            asp-area="" asp-controller="Home" asp-action="ChangeLanguage">
                        Submit
                    </button>
                </div>
            </fieldset>
        </form>
    </div>
}

@* LocalizationExample.cshtml ====================================================*@
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Localization
@using SharedResources04

@model LocalizationExampleViewModel

@* Here is of course the Dependency Injection (DI) coming in and filling
all the dependencies. The key thing is we are asking for a specific
type=SharedResource.
If it doesn't work for you, you can try to use full class name
in your DI instruction, like this one:
@inject IStringLocalizer<SharedResources04.SharedResource> StringLocalizer
 *@

@inject IStringLocalizer<SharedResource> StringLocalizer

@{
    <div style="width:600px">
        <p class="text-success">
            Controller Thread Culture:  <br />
            @Model.ThreadCultureInController
        </p>

        <p class="text-primary">
            Localized In Controller By ResourceManager: <br />
            @Model.LocalizedInControllerByResourceManager1
        </p>

        <p class="text-primary">
            Localized In Controller By ResourceManager: <br />
            @Model.LocalizedInControllerByResourceManager2
        </p>

        <p class="text-primary">
            Localized In Controller By IStringLocalizer: <br />
            @Model.LocalizedInControllerByIStringLocalizer1
        </p>

        <p class="text-primary">
            Localized In Controller By IStringLocalizer: <br />
            @Model.LocalizedInControllerByIStringLocalizer2
        </p>

        <p class="text-success">
            @{
                string text = "Thread CurrentUICulture is [" +
                @Thread.CurrentThread.CurrentUICulture.ToString() + "] ; ";
                text += "Thread CurrentCulture is [" +
                @Thread.CurrentThread.CurrentCulture.ToString() + "]";
            }
            View Thread Culture:  <br />
            @text
        </p>

        <p class="text-primary">
            Localized In View By ResourceManager: <br />
            @SharedResources04.Resources.SharedResource.Wellcome
        </p>

        <p class="text-primary">
            Localized In View By ResourceManager: <br />
            @SharedResources04.Resources.SharedResource.Hello_World
        </p>

        <p class="text-primary">
            Localized In View By IStringLocalizer: <br />
            @StringLocalizer["Wellcome"]
        </p>

        <p class="text-primary">
            Localized In View By IStringLocalizer: <br />
            @StringLocalizer["Hello World"]
        </p>

    </div>
}

6 References

[1] https://learn.microsoft.com/en-us/aspnet/core/fundamentals/localization/make-content-localizable?view=aspnetcore-8.0
Make an ASP.NET Core app's content localizable

[2] https://learn.microsoft.com/en-us/aspnet/core/fundamentals/localization/provide-resources?view=aspnetcore-8.0
Provide localized resources for languages and cultures in an ASP.NET Core app

[3] https://learn.microsoft.com/en-us/aspnet/core/fundamentals/localization/select-language-culture?view=aspnetcore-8.0
Implement a strategy to select the language/culture for each request in a localized ASP.NET Core app

[4] https://learn.microsoft.com/en-us/aspnet/core/fundamentals/localization?view=aspnetcore-8.0
Globalization and localization in ASP.NET Core

[5] https://learn.microsoft.com/en-us/aspnet/core/fundamentals/troubleshoot-aspnet-core-localization?view=aspnetcore-8.0
Troubleshoot ASP.NET Core Localization

[6] https://stackoverflow.com/questions/42647384/asp-net-core-localization-with-help-of-sharedresources
ASP.NET Core Localization with help of SharedResources

[99] https://github.com/MarkPelf/AspNet8MultilingualApplicationWithSingleResxFile

ASP.NET 8 – Multilingual Application with single Resx file – Part 3 – Forms Validation Strings

ASP.NET 8 – Multilingual Application with single Resx file – Part 3 – Forms Validation Strings

A practical guide to building a multi-language Asp.Net 8 MVC application.

Abstract:. A practical guide to building a multi-language Asp.Net 8 MVC application where all language resource strings are kept in a single shared file, as opposed to having separate resource files for each controller/view. In this part, we focus on the localization of form validation error strings.

1 Multilingual form validation error strings

A separate task is how to handle in Asp.Net MVC application a localization of form validation error strings, or so-called Data Annotation Localization. That is the focus of this article.

2 Other articles in this series

Articles in this series are:
ASP.NET 8 – Multilingual Application with single Resx file – Part 1
ASP.NET 8 – Multilingual Application with single Resx file – Part 2 – Alternative Approach
ASP.NET 8 – Multilingual Application with single Resx file – Part 3 – Form Validation Strings
ASP.NET 8 – Multilingual Application with single Resx file – Part 4 – Resource Manager

3 Shared Resources approach

By default, Asp.Net Core 8 MVC technology envisions separate resource file .resx for each controller and the view. But most people do not like it, since most multilanguage strings are the same in different places in the application, we would like it to be all in the same place. Literature [1] calls that approach the “Shared Resources” approach.
In order to implement it, we will create a marker class SharedResoureces.cs to group all the resources. Then in our application, we will invoke Dependency Injection (DI) for that particular class/type instead of a specific controller/view. That is a little trick mentioned in Microsoft documentation [1] that has been a source of confusion in StackOverflow articles [6]. We plan to demystify it here. While everything is explained in [1], what is needed are some practical examples, like the one we provide here.

4 Steps to Multilingual Application

4.1 Configuring Localization Services and Middleware

Localization services are configured in Program.cs:

private static void AddingMultiLanguageSupportServices(WebApplicationBuilder? builder)
{
    if (builder == null) { throw new Exception("builder==null"); };

    builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
    builder.Services.AddMvc()
            .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
            .AddDataAnnotationsLocalization(options =>
            {
                options.DataAnnotationLocalizerProvider = (type, factory) =>
                    factory.Create(typeof(SharedResource));
            });

    builder.Services.Configure<RequestLocalizationOptions>(options =>
    {
        var supportedCultures = new[] { "en", "fr", "de", "it" };
        options.SetDefaultCulture(supportedCultures[0])
            .AddSupportedCultures(supportedCultures)
            .AddSupportedUICultures(supportedCultures);
    });
}

private static void AddingMultiLanguageSupport(WebApplication? app)
{
    app?.UseRequestLocalization();
}

4.2 Create marker class SharedResources.cs

This is just a dummy marker class to group shared resources. We need it for its name and type.
It seems the namespace needs to be the same as the app root namespace, which needs to be the same as the assembly name. I had some problems when changing the namespace, it would not work. If it doesn't work for you, you can try to use the full class name in your DI instruction, like this one:
IStringLocalizer<SharedResources01.SharedResource> StringLocalizer

There is no magic in the name "SharedResource", you can name it "MyResources" and change all references in the code to "MyResources" and all will still work.

The location seems can be any folder, although some articles ([6] claim it needs to be the root project folder I do not see such problems in this example. To me looks like it can be any folder, just keep your namespace tidy.


//SharedResource.cs===================================================
namespace SharedResources03
{
    /*
    * This is just a dummy marker class to group shared resources
    * We need it for its name and type
    * 
    * It seems the namespace needs to be the same as app root namespace
    * which needs to be the same as the assembly name.
    * I had some problems when changing the namespace, it would not work.
    * If it doesn't work for you, you can try to use full class name
    * in your DI instruction, like this one: SharedResources03.SharedResource
    * 
    * There is no magic in the name "SharedResource", you can
    * name it "MyResources" and change all references in the code
    * to "MyResources" and all will still work
    * 
    * Location seems can be any folder, although some
    * articles claim it needs to be the root project folder
    * I do not see such problems in this example. 
    * To me looks it can be any folder, just keep your
    * namespace tidy. 
    */

    public class SharedResource
    {
    }
}

4.3 Create language resources files

In the folder “Resources” create your language resources files, and make sure you name them SharedResources.xx.resx.

4.4 Selecting Language/Culture

Based on [5], the Localization service has three default providers:

  1. QueryStringRequestCultureProvider
  2. CookieRequestCultureProvider
  3. AcceptLanguageHeaderRequestCultureProvider

Since most apps will often provide a mechanism to set the culture with the ASP.NET Core culture cookie, we will focus only on that approach in our example.
This is the code to set .AspNetCore.Culture cookie:

private void ChangeLanguage_SetCookie(HttpContext myContext, string? culture)
{
    if (culture == null) { throw new Exception("culture == null"); };

    //this code sets .AspNetCore.Culture cookie
    CookieOptions cookieOptions=new CookieOptions();
    cookieOptions.Expires = DateTimeOffset.UtcNow.AddMonths(1);
    cookieOptions.IsEssential = true;

    myContext.Response.Cookies.Append(
        CookieRequestCultureProvider.DefaultCookieName,
        CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
        cookieOptions
    );
}

Cookie can be easily seen with Chrome DevTools:

I built a small application to demo it, and here is the screen where I can change the language:

Note that I added some debugging info into the footer, to show the value of the Request language cookie, to see if the app is working as desired.

4.5 Using Data Annotation – Field Validation

In your model class, you set up validation attributes with proper strings that need to be localized.

//LocalizationExampleViewModel.cs===============================================
namespace SharedResources03.Models.Home
{    public class LocalizationExampleViewModel
    {
        /* It is these field validation error messages
         * that are focus of this example. We want to
         * be able to present them in multiple languages
         */
        //model
        [Required(ErrorMessage = "The UserName field is required.")]
        [Display(Name = "UserName")]
        public string? UserName { get; set; }

        [EmailAddress(ErrorMessage = "The Email field is not a valid email address.")]
        [Display(Name = "Email")]
        public string? Email { get; set; }

        public bool IsSubmit { get; set; } = false;
    }
}

For model-level validation in the controller, we will use classical IStringLocalizer.

public class HomeController : Controller
{
    private readonly ILogger<HomeController> _logger;
    private readonly IStringLocalizer<SharedResource> _stringLocalizer;

    public HomeController(ILogger<HomeController> logger,
        IStringLocalizer<SharedResource> stringLocalizer)
    {
        _logger = logger;
        _stringLocalizer = stringLocalizer;
    }

    //--------------------------

    public IActionResult LocalizationExample(LocalizationExampleViewModel model)
{
    if(model.IsSubmit)
    {
        if (!ModelState.IsValid)
        {
            ModelState.AddModelError("", _stringLocalizer["Please correct all errors and submit again"]);
        }
    }
    else
    {
        ModelState.Clear();
    }

    return View(model);
}

4.6 Synchronization Asp.Net CSS error classes with Bootstrap CSS classes

Asp.Net will add CSS class .input-validation-error to form a field with an error. But, Bootstrap does not know what to do with that CSS class, so that class needs to be mapped to CSS class that Bootstrap understands, and that is CSS class .is-invalid.
That is the purpose of this JavaScript code that is here. Of course, we hook to the DOMContentLoaded event and do the mapping of CSS classes.
All this is to sync Asp.Net CSS error classes with Bootstrap CSS classes to mark error input elements border to red by Bootstrap.
The final result is that the red line on the form control marks an invalid field.

@* _ValidationClassesSyncBetweenAspNetAndBootstrap.cshtml===================== *@
@*
All this is to sync Asp.Net CSS error classes with Bootstrap CSS classes to
mark error input elements border to red by Bootstrap.

Asp.Net will add CSS class .input-validation-error to form a field with an error.
But, Bootstrap does not know what to do with that CSS class, so that class
needs to be mapped to CSS class that Bootstrap understands, and that is
CSS class .is-invalid.

That is the purpose of this JavaScript code that is here. Of course, we hook
to DOMContentLoaded event and do the mapping of CSS classes.

The final result is that the red line on the form control marking an invalid field.
*@

<script type="text/javascript">
    window.addEventListener("DOMContentLoaded", () => {
        document.querySelectorAll("input.input-validation-error")
            .forEach((elem) => { elem.classList.add("is-invalid"); }
            );
    });
</script>

4.7 Sample view with field and model validation messages

Here is our sample view.

@* LocalizationExample.cshtml ====================================================*@
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Localization

@model LocalizationExampleViewModel

@{
    <partial name="_ValidationClassesSyncBetweenAspNetAndBootstrap" />

    <div style="width:500px ">
        <fieldset class="border rounded-3 p-3 bg-info">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <form id="formlogin" novalidate>
                <div class="form-group">
                    <label asp-for="UserName"></label>
                    <div>
                        <span asp-validation-for="UserName" class="text-danger"></span>
                    </div>
                    <input class="form-control" asp-for="UserName" />
                </div>
                <div class="form-group">
                    <label asp-for="Email"></label>
                    <div>
                        <span asp-validation-for="Email" class="text-danger"></span>
                    </div>
                    <input class="form-control" asp-for="Email" />
                </div>
                <input type="hidden" name="IsSubmit" value="true">
                <button type="submit" class="btn btn-primary mt-3 float-end"> Submit</button>
            </form>
        </fieldset>
    </div>    
}

Note that we used novalidate attribute in the form element to suppress browser-level integrated validation that is popping out and is not localized.
So, there are 3 possible levels of validation:

  1. Server-side validation – used in this example, and is localized (multilingual)
  2. Client-side validation – in Asp.Net it can be enabled by using jquery.validate.unobtrusive.min.js, but we are not using it in this example.
  3. Browser-integrated validation – disabled in this example by the usage of novalidate attribute, because it is not localized, and is always in English.

If you do not set novalidate attribute, the browser will pop up its validation dialog and you will see messages as on the following screen. It can be confusing to the user to multiple different messages.

4.8 Execution result

Here is what the execution result looks like:

Note that I added some debugging info into the footer, to show the value of the Request language cookie, to see if the app is working as desired.

5 Full Code

Since most people like code they can copy-paste, here is the full code of the application. Code can be downladed at GitHub [99].


//Program.cs===========================================================================
namespace SharedResources03
{
    public class Program
    {
        public static void Main(string[] args)
        {
            //=====Middleware and Services=============================================
            var builder = WebApplication.CreateBuilder(args);

            //adding multi-language support
            AddingMultiLanguageSupportServices(builder);

            // Add services to the container.
            builder.Services.AddControllersWithViews();

            //====App===================================================================
            var app = builder.Build();

            //adding multi-language support
            AddingMultiLanguageSupport(app);

            // Configure the HTTP request pipeline.
            if (!app.Environment.IsDevelopment())
            {
                app.UseExceptionHandler("/Home/Error");
            }
            app.UseStaticFiles();

            app.UseRouting();

            app.UseAuthorization();

            app.MapControllerRoute(
                name: "default",
                pattern: "{controller=Home}/{action=ChangeLanguage}/{id?}");

            app.Run();
        }

        private static void AddingMultiLanguageSupportServices(WebApplicationBuilder? builder)
        {
            if (builder == null) { throw new Exception("builder==null"); };

            builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
            builder.Services.AddMvc()
                    .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
                    .AddDataAnnotationsLocalization(options =>
                    {
                        options.DataAnnotationLocalizerProvider = (type, factory) =>
                            factory.Create(typeof(SharedResource));
                    });

            builder.Services.Configure<RequestLocalizationOptions>(options =>
            {
                var supportedCultures = new[] { "en", "fr", "de", "it" };
                options.SetDefaultCulture(supportedCultures[0])
                    .AddSupportedCultures(supportedCultures)
                    .AddSupportedUICultures(supportedCultures);
            });
        }

        private static void AddingMultiLanguageSupport(WebApplication? app)
        {
            app?.UseRequestLocalization();
        }
    }
}

//SharedResource.cs===================================================
namespace SharedResources03
{
    /*
    * This is just a dummy marker class to group shared resources
    * We need it for its name and type
    * 
    * It seems the namespace needs to be the same as app root namespace
    * which needs to be the same as the assembly name.
    * I had some problems when changing the namespace, it would not work.
    * If it doesn't work for you, you can try to use full class name
    * in your DI instruction, like this one: SharedResources03.SharedResource
    * 
    * There is no magic in the name "SharedResource", you can
    * name it "MyResources" and change all references in the code
    * to "MyResources" and all will still work
    * 
    * Location seems can be any folder, although some
    * articles claim it needs to be the root project folder
    * I do not see such problems in this example. 
    * To me looks it can be any folder, just keep your
    * namespace tidy. 
    */

    public class SharedResource
    {
    }
}

//ChangeLanguageViewModel.cs=====================================================
namespace SharedResources03.Models.Home
{
    public class ChangeLanguageViewModel
    {
        //model
        public string? SelectedLanguage { get; set; } = "en";

        public bool IsSubmit { get; set; } = false;

        //view model
        public List<SelectListItem>? ListOfLanguages { get; set; }
    }
}

//LocalizationExampleViewModel.cs===============================================
namespace SharedResources03.Models.Home
{    public class LocalizationExampleViewModel
    {
        /* It is these field validation error messages
         * that are focus of this example. We want to
         * be able to present them in multiple languages
         */
        //model
        [Required(ErrorMessage = "The UserName field is required.")]
        [Display(Name = "UserName")]
        public string? UserName { get; set; }

        [EmailAddress(ErrorMessage = "The Email field is not a valid email address.")]
        [Display(Name = "Email")]
        public string? Email { get; set; }

        public bool IsSubmit { get; set; } = false;
    }
}

//HomeController.cs================================================================
namespace SharedResources03.Controllers
{
    public class HomeController : Controller
    {
        private readonly ILogger<HomeController> _logger;
        private readonly IStringLocalizer<SharedResource> _stringLocalizer;

        public HomeController(ILogger<HomeController> logger,
            IStringLocalizer<SharedResource> stringLocalizer)
        {
            _logger = logger;
            _stringLocalizer = stringLocalizer;
        }

        public IActionResult ChangeLanguage(ChangeLanguageViewModel model)
        {
            if (model.IsSubmit)
            {
                HttpContext myContext = this.HttpContext;
                ChangeLanguage_SetCookie(myContext, model.SelectedLanguage);
                //doing funny redirect to get new Request Cookie
                //for presentation
                return LocalRedirect("/Home/ChangeLanguage");
            }

            //prepare presentation
            ChangeLanguage_PreparePresentation(model);
            return View(model);
        }

        private void ChangeLanguage_PreparePresentation(ChangeLanguageViewModel model)
        {
            model.ListOfLanguages = new List<SelectListItem>
                        {
                            new SelectListItem
                            {
                                Text = "English",
                                Value = "en"
                            },

                            new SelectListItem
                            {
                                Text = "German",
                                Value = "de",
                            },

                            new SelectListItem
                            {
                                Text = "French",
                                Value = "fr"
                            },

                            new SelectListItem
                            {
                                Text = "Italian",
                                Value = "it"
                            }
                        };
        }

        private void ChangeLanguage_SetCookie(HttpContext myContext, string? culture)
        {
            if (culture == null) { throw new Exception("culture == null"); };

            //this code sets .AspNetCore.Culture cookie
            CookieOptions cookieOptions=new CookieOptions();
            cookieOptions.Expires = DateTimeOffset.UtcNow.AddMonths(1);
            cookieOptions.IsEssential = true;

            myContext.Response.Cookies.Append(
                CookieRequestCultureProvider.DefaultCookieName,
                CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
                cookieOptions
            );
        }

        public IActionResult LocalizationExample(LocalizationExampleViewModel model)
        {
            if(model.IsSubmit)
            {
                if (!ModelState.IsValid)
                {
                    ModelState.AddModelError("", _stringLocalizer["Please correct all errors and submit again"]);
                }
            }
            else
            {
                ModelState.Clear();
            }

            return View(model);
        }

        public IActionResult Error()
        {
            return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
        }
    }
}
@* _ValidationClassesSyncBetweenAspNetAndBootstrap.cshtml===================== *@
@*
All this is to sync Asp.Net CSS error classes with Bootstrap CSS classes to
mark error input elements border to red by Bootstrap.

Asp.Net will add CSS class .input-validation-error to form a field with an error.
But, Bootstrap does not know what to do with that CSS class, so that class
needs to be mapped to CSS class that Bootstrap understands, and that is
CSS class .is-invalid.

That is the purpose of this JavaScript code that is here. Of course, we hook
to DOMContentLoaded event and do the mapping of CSS classes.

The final result is that the red line on the form control marking an invalid field.
*@

<script type="text/javascript">
    window.addEventListener("DOMContentLoaded", () => {
        document.querySelectorAll("input.input-validation-error")
            .forEach((elem) => { elem.classList.add("is-invalid"); }
            );
    });
</script>

@* ChangeLanguage.cshtml ===================================================*@
@model ChangeLanguageViewModel

@{
    <div style="width:500px">
        <p class="bg-info">
            <partial name="_Debug.AspNetCore.CultureCookie" /><br />
        </p>

        <form id="form1" >
            <fieldset class="border rounded-3 p-3">
                <legend class="float-none w-auto px-3">Change Language</legend>
                <div class="form-group">
                    <label asp-for="SelectedLanguage">Select Language</label>
                    <select class="form-select" asp-for="SelectedLanguage"
                            asp-items="@Model.ListOfLanguages">
                    </select>
                    <input type="hidden" name="IsSubmit" value="true">
                    <button type="submit" form="form1" class="btn btn-primary mt-3 float-end"
                            asp-area="" asp-controller="Home" asp-action="ChangeLanguage">
                        Submit
                    </button>
                </div>
            </fieldset>
        </form>
    </div>
}

@* LocalizationExample.cshtml ====================================================*@
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Localization

@model LocalizationExampleViewModel

@{
    <partial name="_ValidationClassesSyncBetweenAspNetAndBootstrap" />

    <div style="width:500px ">
        <fieldset class="border rounded-3 p-3 bg-info">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <form id="formlogin" novalidate>
                <div class="form-group">
                    <label asp-for="UserName"></label>
                    <div>
                        <span asp-validation-for="UserName" class="text-danger"></span>
                    </div>
                    <input class="form-control" asp-for="UserName" />
                </div>
                <div class="form-group">
                    <label asp-for="Email"></label>
                    <div>
                        <span asp-validation-for="Email" class="text-danger"></span>
                    </div>
                    <input class="form-control" asp-for="Email" />
                </div>
                <input type="hidden" name="IsSubmit" value="true">
                <button type="submit" class="btn btn-primary mt-3 float-end"> Submit</button>
            </form>
        </fieldset>
    </div>    
}

6 References

[1] https://learn.microsoft.com/en-us/aspnet/core/fundamentals/localization/make-content-localizable?view=aspnetcore-8.0
Make an ASP.NET Core app's content localizable

[2] https://learn.microsoft.com/en-us/aspnet/core/fundamentals/localization/provide-resources?view=aspnetcore-8.0
Provide localized resources for languages and cultures in an ASP.NET Core app

[3] https://learn.microsoft.com/en-us/aspnet/core/fundamentals/localization/select-language-culture?view=aspnetcore-8.0
Implement a strategy to select the language/culture for each request in a localized ASP.NET Core app

[4] https://learn.microsoft.com/en-us/aspnet/core/fundamentals/localization?view=aspnetcore-8.0
Globalization and localization in ASP.NET Core

[5] https://learn.microsoft.com/en-us/aspnet/core/fundamentals/troubleshoot-aspnet-core-localization?view=aspnetcore-8.0
Troubleshoot ASP.NET Core Localization

[6] https://stackoverflow.com/questions/42647384/asp-net-core-localization-with-help-of-sharedresources
ASP.NET Core Localization with help of SharedResources

[99] https://github.com/MarkPelf/AspNet8MultilingualApplicationWithSingleResxFile

Asp.Net 8 – Multilingual Application with single Resx file – Part2 – Alternative Approach

Asp.Net 8 – Multilingual Application with single Resx file – Part2 – Alternative Approach

A practical guide to building a multi-language Asp.Net 8 MVC application.

Abstract:. A practical guide to building a multi-language Asp.Net 8 MVC application where all language resource strings are kept in a single shared file, as opposed to having separate resource files for each controller/view. Here we show a variant of a solution different from the previous article in the series.

1 This is a variant of the previous article's solution

In this article, we show a variant of a solution in a previous article on how to solve the issue of having only one Resx file of language strings. We are showing this variant because it has been a popular approach on the internet (see [7], [8], [9]) although basic work principles are the same as in the previous article. This approach is a kind of usage of a helper/wrapper object to achieve the same result.
I personally like more the direct approach from the previous article, but this approach is quite popular on the internet, so it is up to the developer to choose according to his/her preferences.

2 Articles in this series

Articles in this series are:
ASP.NET 8 – Multilingual Application with single Resx file – Part 1
ASP.NET 8 – Multilingual Application with single Resx file – Part 2 – Alternative Approach
ASP.NET 8 – Multilingual Application with single Resx file – Part 3 – Form Validation Strings
ASP.NET 8 – Multilingual Application with single Resx file – Part 4 –Resource Manager

3 Shared Resources approach

By default, Asp.Net Core 8 MVC technology envisions separate resource file .resx for each controller and the view. But most people do not like it, since most multilanguage strings are the same in different places in the application, we would like it to be all in the same place. Literature [1] calls that approach the “Shared Resources” approach. In order to implement it, we will create a marker class SharedResoureces.cs to group all the resources.
Then in our app, we use a factory function to create a StringLocalizer service focused on that class/type and wrap it into the helper object called “SharedStringLocalizer”.
Then in our application, we will use Dependency Injection (DI) to inject that wrapper object/service into methods where we need Localization Services.
The main difference from the solution from the previous article in this series is that instead of using DI to inject directly IStringLocalizer<SharedResource>, we wrap it into the helper object “SharedStringLocalizer” and then inject that helper object instead. The underlying principles of how it works are the same.

4 Steps to Multilingual Application

4.1 Create marker class SharedResources.cs

This is just a dummy marker class to group shared resources. We need it for its name and type.
It seems the namespace needs to be the same as the app root namespace, which needs to be the same as the assembly name. I had some problems when changing the namespace, it would not work

There is no magic in the name "SharedResource", you can name it "MyResources" and change all references in the code to "MyResources" and all will still work.

The location seems can be any folder, although some articles ([6]) claim it needs to be the root project folder I do not see such problems in this example. To me looks like it can be any folder, just keep your namespace tidy.

//SharedResource.cs===================================================
namespace SharedResources02
{
    /*
    * This is just a dummy marker class to group shared resources
    * We need it for its name and type
    * 
    * It seems the namespace needs to be the same as app root namespace
    * which needs to be the same as the assembly name.
    * I had some problems when changing the namespace, it would not work.
    * If it doesn't work for you, you can try to use full class name
    * in your DI instruction, like this one: SharedResources02.SharedResource
    * 
    * There is no magic in the name "SharedResource", you can
    * name it "MyResources" and change all references in the code
    * to "MyResources" and all will still work
    * 
    * Location seems can be any folder, although some
    * articles claim it needs to be the root project folder
    * I do not see such problems in this example. 
    * To me looks it can be any folder, just keep your
    * namespace tidy. 
    */

    public class SharedResource
    {
    }
}

4.2 Create wrapper helper classes

We will create wrapper helper classes/services that we will inject using DI into our code.

//ISharedStringLocalizer.cs=================================
namespace SharedResources02
{
    //we create this interface to use it for DI dependency setting
    public interface ISharedStringLocalizer
    {
        public LocalizedString this[string key]
        {
            get;
        }

        LocalizedString GetLocalizedString(string key);
    }
}

//SharedStringLocalizer.cs==================================================
namespace SharedResources02
{
    //we create this helper/wrapper class/service
    //that we are going to pass around in DI
    public class SharedStringLocalizer : ISharedStringLocalizer
    {
        //here is object that is doing the real work
        //it is almost the same as IStringLocalizer<SharedResource>
        private readonly IStringLocalizer localizer;

        public SharedStringLocalizer(IStringLocalizerFactory factory)
        {
            var type = typeof(SharedResource);
            var assemblyName = new AssemblyName(
                type.GetTypeInfo().Assembly.FullName ?? String.Empty);
            this.localizer = factory.Create("SharedResource", 
                assemblyName?.Name ?? String.Empty);
        }

        //in our methods we just pass work to internal object
        public LocalizedString this[string key] => this.localizer[key];

        public LocalizedString GetLocalizedString(string key)
        {
            return this.GetLocalizedString(key);
        }
    }
}

//ISharedHtmlLocalizer.cs===============================================
namespace SharedResources02
{
    //we create this interface to use it for DI dependency setting
    public interface ISharedHtmlLocalizer
    {
        public LocalizedHtmlString this[string key]
        {
            get;
        }

        LocalizedHtmlString GetLocalizedString(string key);
    }
}

//SharedHtmlLocalizer.cs==================================================
namespace SharedResources02
{
    //we create this helper/wrapper class/service
    //that we are going to pass around in DI
    public class SharedHtmlLocalizer: ISharedHtmlLocalizer
    {
        //here is object that is doing the real work
        //it is almost the same as IHtmlLocalizer<SharedResource>
        private readonly IHtmlLocalizer localizer;

        public SharedHtmlLocalizer(IHtmlLocalizerFactory factory)
        {
            var type = typeof(SharedResource);
            var assemblyName = new AssemblyName(
                type.GetTypeInfo().Assembly.FullName ?? String.Empty);
            this.localizer = factory.Create("SharedResource", 
                assemblyName?.Name ?? String.Empty);
        }

        //in our methods we just pass work to internal object
        public LocalizedHtmlString this[string key] => this.localizer[key];

        public LocalizedHtmlString GetLocalizedString(string key)
        {
            return this.GetLocalizedString(key);
        }
    }
}

4.3 Create language resources files

In the folder “Resources” create your language resources files, and make sure you name them SharedResources.xx.resx.

4.4 Configuring Localization Services and Middleware

Localization services are configured in Program.cs:

private static void AddingMultiLanguageSupportServices(WebApplicationBuilder? builder)
{
    if (builder == null) { throw new Exception("builder==null"); };

    builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
    builder.Services.AddMvc()
            .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix);
    builder.Services.Configure<RequestLocalizationOptions>(options =>
    {
        var supportedCultures = new[] { "en", "fr", "de", "it" };
        options.SetDefaultCulture(supportedCultures[0])
            .AddSupportedCultures(supportedCultures)
            .AddSupportedUICultures(supportedCultures);
    });
    builder.Services.AddSingleton<ISharedStringLocalizer, SharedStringLocalizer>();
    builder.Services.AddSingleton<ISharedHtmlLocalizer, SharedHtmlLocalizer>();
}

private static void AddingMultiLanguageSupport(WebApplication? app)
{
    app?.UseRequestLocalization();
}

4.5 Selecting Language/Culture

Based on [5], the Localization service has three default providers:

  1. QueryStringRequestCultureProvider
  2. CookieRequestCultureProvider
  3. AcceptLanguageHeaderRequestCultureProvider

Since most apps will often provide a mechanism to set the culture with the ASP.NET Core culture cookie, we will focus only on that approach in our example.
This is the code to set .AspNetCore.Culture cookie:

private void ChangeLanguage_SetCookie(HttpContext myContext, string? culture)
{
    if(culture == null) { throw new Exception("culture == null"); };

    //this code sets .AspNetCore.Culture cookie
    myContext.Response.Cookies.Append(
        CookieRequestCultureProvider.DefaultCookieName,
        CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
        new CookieOptions { Expires = DateTimeOffset.UtcNow.AddMonths(1) }
    );
}

Cookie can be easily seen with Chrome DevTools:

I built a small application to demo it, and here is the screen where I change the language:

Note that I added some debugging info into the footer, to show the value of the Request language cookie, to see if the app is working as desired.

4.6 Using Localization Services in the Controller

In the Controller is of course the Dependency Injection (DI) coming in and filling all the dependencies. So, here will services SharedStringLocalizer and SharedHtmlLocalizer be injected. Here is the code snippet:


public class HomeController : Controller
{
    private readonly ILogger<HomeController> _logger;
    private readonly ISharedStringLocalizer _stringLocalizer;
    private readonly ISharedHtmlLocalizer _htmlLocalizer;

    /* Here is of course the Dependency Injection (DI) coming in and filling 
     * all the dependencies. 
     * So, here will services SharedStringLocalizer and SharedHtmlLocalizer
     * be injected
     */
    public HomeController(ILogger<HomeController> logger,
        ISharedStringLocalizer stringLocalizer,
        ISharedHtmlLocalizer htmlLocalizer)
    {
        _logger = logger;
        _stringLocalizer = stringLocalizer;
        _htmlLocalizer = htmlLocalizer;
    }

    public IActionResult LocalizationExample(LocalizationExampleViewModel model)
{
    //so, here we use ISharedStringLocalizer
    model.IStringLocalizerInController = _stringLocalizer["Wellcome"];
    //so, here we use ISharedHtmlLocalizer
    model.IHtmlLocalizerInController = _htmlLocalizer["Wellcome"];
    return View(model);
}

4.7 Using Localization Services in the View

In the View is of course the Dependency Injection (DI) coming in and filling all the dependencies. So, here will services SharedStringLocalizer and SharedHtmlLocalizer be injected. Here is the code snippet:

@* LocalizationExample.cshtml ====================================================*@
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Localization

@model LocalizationExampleViewModel

@* Here is of course the Dependency Injection (DI) coming in and filling
 all the dependencies.
 So, here will services SharedStringLocalizer and SharedHtmlLocalizer
 be injected
*@

@inject ISharedStringLocalizer StringLocalizer
@inject ISharedHtmlLocalizer HtmlLocalizer

@{
    <div style="width:600px">
        <p class="bg-info">
            ISharedStringLocalizer Localized  in Controller:
            @Model.IStringLocalizerInController
        </p>

        <p class="bg-info">
            @{
                string? text1 = StringLocalizer["Wellcome"];
            }
            ISharedStringLocalizer Localized  in View: @text1
        </p>

        <p class="bg-info">
            ISharedHtmlLocalizer Localized  in Controller:
            @Model.IHtmlLocalizerInController
        </p>

        <p class="bg-info">
            @{
                string? text2 = "Wellcome";
            }
            ISharedHtmlLocalizer Localized  in View: @HtmlLocalizer[@text2]
        </p>
    </div>
}

4.8 Execution result

Here is what the execution result looks like:

Note that I added some debugging info into the footer, to show the value of the Request language cookie, to see if the app is working as desired.

5 Full Code

Since most people like code they can copy-paste, here is the full code of the application. Code can be downladed at GitHub [99].

//SharedResource.cs===================================================
namespace SharedResources02
{
    /*
    * This is just a dummy marker class to group shared resources
    * We need it for its name and type
    * 
    * It seems the namespace needs to be the same as app root namespace
    * which needs to be the same as the assembly name.
    * I had some problems when changing the namespace, it would not work.
    * If it doesn't work for you, you can try to use full class name
    * in your DI instruction, like this one: SharedResources02.SharedResource
    * 
    * There is no magic in the name "SharedResource", you can
    * name it "MyResources" and change all references in the code
    * to "MyResources" and all will still work
    * 
    * Location seems can be any folder, although some
    * articles claim it needs to be the root project folder
    * I do not see such problems in this example. 
    * To me looks it can be any folder, just keep your
    * namespace tidy. 
    */

    public class SharedResource
    {
    }
}

//ISharedStringLocalizer.cs=================================
namespace SharedResources02
{
    //we create this interface to use it for DI dependency setting
    public interface ISharedStringLocalizer
    {
        public LocalizedString this[string key]
        {
            get;
        }

        LocalizedString GetLocalizedString(string key);
    }
}

//SharedStringLocalizer.cs==================================================
namespace SharedResources02
{
    //we create this helper/wrapper class/service
    //that we are going to pass around in DI
    public class SharedStringLocalizer : ISharedStringLocalizer
    {
        //here is object that is doing the real work
        //it is almost the same as IStringLocalizer<SharedResource>
        private readonly IStringLocalizer localizer;

        public SharedStringLocalizer(IStringLocalizerFactory factory)
        {
            var type = typeof(SharedResource);
            var assemblyName = new AssemblyName(
                type.GetTypeInfo().Assembly.FullName ?? String.Empty);
            this.localizer = factory.Create("SharedResource", 
                assemblyName?.Name ?? String.Empty);
        }

        //in our methods we just pass work to internal object
        public LocalizedString this[string key] => this.localizer[key];

        public LocalizedString GetLocalizedString(string key)
        {
            return this.GetLocalizedString(key);
        }
    }
}

//ISharedHtmlLocalizer.cs===============================================
namespace SharedResources02
{
    //we create this interface to use it for DI dependency setting
    public interface ISharedHtmlLocalizer
    {
        public LocalizedHtmlString this[string key]
        {
            get;
        }

        LocalizedHtmlString GetLocalizedString(string key);
    }
}

//SharedHtmlLocalizer.cs==================================================
namespace SharedResources02
{
    //we create this helper/wrapper class/service
    //that we are going to pass around in DI
    public class SharedHtmlLocalizer: ISharedHtmlLocalizer
    {
        //here is object that is doing the real work
        //it is almost the same as IHtmlLocalizer<SharedResource>
        private readonly IHtmlLocalizer localizer;

        public SharedHtmlLocalizer(IHtmlLocalizerFactory factory)
        {
            var type = typeof(SharedResource);
            var assemblyName = new AssemblyName(
                type.GetTypeInfo().Assembly.FullName ?? String.Empty);
            this.localizer = factory.Create("SharedResource", 
                assemblyName?.Name ?? String.Empty);
        }

        //in our methods we just pass work to internal object
        public LocalizedHtmlString this[string key] => this.localizer[key];

        public LocalizedHtmlString GetLocalizedString(string key)
        {
            return this.GetLocalizedString(key);
        }
    }
}

//Program.cs===========================================================================
namespace SharedResources02
{
    public class Program
    {
        public static void Main(string[] args)
        {
            //=====Middleware and Services=============================================
            var builder = WebApplication.CreateBuilder(args);

            //adding multi-language support
            AddingMultiLanguageSupportServices(builder);

            // Add services to the container.
            builder.Services.AddControllersWithViews();

            //====App===================================================================
            var app = builder.Build();

            //adding multi-language support
            AddingMultiLanguageSupport(app);

            // Configure the HTTP request pipeline.
            if (!app.Environment.IsDevelopment())
            {
                app.UseExceptionHandler("/Home/Error");
            }
            app.UseStaticFiles();

            app.UseRouting();

            app.UseAuthorization();

            app.MapControllerRoute(
                name: "default",
                pattern: "{controller=Home}/{action=ChangeLanguage}/{id?}");

            app.Run();
        }

        private static void AddingMultiLanguageSupportServices(WebApplicationBuilder? builder)
        {
            if (builder == null) { throw new Exception("builder==null"); };

            builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
            builder.Services.AddMvc()
                    .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix);
            builder.Services.Configure<RequestLocalizationOptions>(options =>
            {
                var supportedCultures = new[] { "en", "fr", "de", "it" };
                options.SetDefaultCulture(supportedCultures[0])
                    .AddSupportedCultures(supportedCultures)
                    .AddSupportedUICultures(supportedCultures);
            });
            builder.Services.AddSingleton<ISharedStringLocalizer, SharedStringLocalizer>();
            builder.Services.AddSingleton<ISharedHtmlLocalizer, SharedHtmlLocalizer>();
        }

        private static void AddingMultiLanguageSupport(WebApplication? app)
        {
            app?.UseRequestLocalization();
        }
    }
}

//HomeController.cs================================================================
namespace SharedResources02.Controllers
{
    public class HomeController : Controller
    {
        private readonly ILogger<HomeController> _logger;
        private readonly ISharedStringLocalizer _stringLocalizer;
        private readonly ISharedHtmlLocalizer _htmlLocalizer;

        /* Here is of course the Dependency Injection (DI) coming in and filling 
         * all the dependencies. 
         * So, here will services SharedStringLocalizer and SharedHtmlLocalizer
         * be injected
         */
        public HomeController(ILogger<HomeController> logger,
            ISharedStringLocalizer stringLocalizer,
            ISharedHtmlLocalizer htmlLocalizer)
        {
            _logger = logger;
            _stringLocalizer = stringLocalizer;
            _htmlLocalizer = htmlLocalizer;
        }

        public IActionResult ChangeLanguage(ChangeLanguageViewModel model)
        {
            if (model.IsSubmit)
            {
                HttpContext myContext = this.HttpContext;
                ChangeLanguage_SetCookie(myContext, model.SelectedLanguage);
                //doing funny redirect to get new Request Cookie
                //for presentation
                return LocalRedirect("/Home/ChangeLanguage");
            }

            //prepare presentation
            ChangeLanguage_PreparePresentation(model);
            return View(model);
        }

        private void ChangeLanguage_PreparePresentation(ChangeLanguageViewModel model)
        {
            model.ListOfLanguages = new List<SelectListItem>
                        {
                            new SelectListItem
                            {
                                Text = "English",
                                Value = "en"
                            },

                            new SelectListItem
                            {
                                Text = "German",
                                Value = "de",
                            },

                            new SelectListItem
                            {
                                Text = "French",
                                Value = "fr"
                            },

                            new SelectListItem
                            {
                                Text = "Italian",
                                Value = "it"
                            }
                        };
        }

        private void ChangeLanguage_SetCookie(HttpContext myContext, string? culture)
        {
            if(culture == null) { throw new Exception("culture == null"); };

            //this code sets .AspNetCore.Culture cookie
            myContext.Response.Cookies.Append(
                CookieRequestCultureProvider.DefaultCookieName,
                CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
                new CookieOptions { Expires = DateTimeOffset.UtcNow.AddMonths(1) }
            );
        }

        public IActionResult LocalizationExample(LocalizationExampleViewModel model)
        {
            //so, here we use ISharedStringLocalizer
            model.IStringLocalizerInController = _stringLocalizer["Wellcome"];
            //so, here we use ISharedHtmlLocalizer
            model.IHtmlLocalizerInController = _htmlLocalizer["Wellcome"];
            return View(model);
        }

        public IActionResult Error()
        {
            return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
        }
    }
}

//ChangeLanguageViewModel.cs=====================================================
namespace SharedResources02.Models.Home
{
    public class ChangeLanguageViewModel
    {
        //model
        public string? SelectedLanguage { get; set; } = "en";

        public bool IsSubmit { get; set; } = false;

        //view model
        public List<SelectListItem>? ListOfLanguages { get; set; }
    }
}

//LocalizationExampleViewModel.cs===============================================
namespace SharedResources02.Models.Home
{
    public class LocalizationExampleViewModel
    {
        public string? IStringLocalizerInController { get; set; }
        public LocalizedHtmlString? IHtmlLocalizerInController { get; set; }
    }
}
@* ChangeLanguage.cshtml ===================================================*@
@model ChangeLanguageViewModel

@{
    <div style="width:500px">
        <p class="bg-info">
            <partial name="_Debug.AspNetCore.CultureCookie" /><br />
        </p>

        <form id="form1">
            <fieldset class="border rounded-3 p-3">
                <legend class="float-none w-auto px-3">Change Language</legend>
                <div class="form-group">
                    <label asp-for="SelectedLanguage">Select Language</label>
                    <select class="form-select" asp-for="SelectedLanguage"
                            asp-items="@Model.ListOfLanguages">
                    </select>
                    <input type="hidden" name="IsSubmit" value="true">
                    <button type="submit" form="form1" class="btn btn-primary mt-3 float-end"
                            asp-area="" asp-controller="Home" asp-action="ChangeLanguage">
                        Submit
                    </button>
                </div>
            </fieldset>
        </form>
    </div>
}

@* LocalizationExample.cshtml ====================================================*@
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Localization

@model LocalizationExampleViewModel

@* Here is of course the Dependency Injection (DI) coming in and filling
 all the dependencies.
 So, here will services SharedStringLocalizer and SharedHtmlLocalizer
 be injected
*@

@inject ISharedStringLocalizer StringLocalizer
@inject ISharedHtmlLocalizer HtmlLocalizer

@{
    <div style="width:600px">
        <p class="bg-info">
            ISharedStringLocalizer Localized  in Controller:
            @Model.IStringLocalizerInController
        </p>

        <p class="bg-info">
            @{
                string? text1 = StringLocalizer["Wellcome"];
            }
            ISharedStringLocalizer Localized  in View: @text1
        </p>

        <p class="bg-info">
            ISharedHtmlLocalizer Localized  in Controller:
            @Model.IHtmlLocalizerInController
        </p>

        <p class="bg-info">
            @{
                string? text2 = "Wellcome";
            }
            ISharedHtmlLocalizer Localized  in View: @HtmlLocalizer[@text2]
        </p>
    </div>
}

6 References

[1] https://learn.microsoft.com/en-us/aspnet/core/fundamentals/localization/make-content-localizable?view=aspnetcore-8.0
Make an ASP.NET Core app's content localizable

[2] https://learn.microsoft.com/en-us/aspnet/core/fundamentals/localization/provide-resources?view=aspnetcore-8.0
Provide localized resources for languages and cultures in an ASP.NET Core app

[3] https://learn.microsoft.com/en-us/aspnet/core/fundamentals/localization/select-language-culture?view=aspnetcore-8.0
Implement a strategy to select the language/culture for each request in a localized ASP.NET Core app

[4] https://learn.microsoft.com/en-us/aspnet/core/fundamentals/localization?view=aspnetcore-8.0
Globalization and localization in ASP.NET Core

[5] https://learn.microsoft.com/en-us/aspnet/core/fundamentals/troubleshoot-aspnet-core-localization?view=aspnetcore-8.0
Troubleshoot ASP.NET Core Localization

[6] https://stackoverflow.com/questions/42647384/asp-net-core-localization-with-help-of-sharedresources
ASP.NET Core Localization with help of SharedResources

[7] https://muratsuzen.medium.com/adding-multiple-languages-with-asp-net-core-mvc-c1cb85929bed
Adding Multiple Languages with ASP.NET Core MVC

[8] https://medium.com/@flouss/asp-net-core-localization-one-resx-to-rule-them-all-de5c07692fa4
ASP.Net Core Localization: One RESX to rule them all

[9] https://stackoverflow.com/questions/61752576/localization-using-single-resource-file-for-views-in-asp-net-core-3-1
Localization using single resource file for Views in Asp .Net Core 3.1

[99] https://github.com/MarkPelf/AspNet8MultilingualApplicationWithSingleResxFile

Asp.Net 8 – Multilingual Application with single Resx file – Part1

Asp.Net 8 – Multilingual Application with single Resx file - Part1

A practical guide to building a multi-language Asp.Net 8 MVC application.

Abstract: A practical guide to building a multi-language Asp.Net 8 MVC application where all language resource strings are kept in a single shared file, as opposed to having separate resource files for each controller/view.

1 The need for a newer tutorial

There are a number of tutorials on how to build a multi-language application Asp.Net Core 8 MVC, but many are outdated for older versions of .NET or are vague on how to resolve the problem of having all language resources strings in a single file. So, the plan is to provide practical instructions on how that can be done, accompanied by code samples and a proof-of-concept example application.

1.1 Articles in this series

Articles in this series are:
ASP.NET 8 – Multilingual Application with single Resx file - Part 1
ASP.NET 8 – Multilingual Application with single Resx file – Part 2 – Alternative Approach
ASP.NET 8 – Multilingual Application with single Resx file – Part 3 – Form Validation Strings
ASP.NET 8 – Multilingual Application with single Resx file – Part 4 – Resource Manager

2 Multilingual sites, Globalization and Localization

I am not going to explain here what are benefits of having a site in multiple languages, and what are Localization and Globalization. You can read it in many places on the internet (see [4]). I am going to focus on how to practically build such a site in Asp.Net Core 8 MVC. If you are not sure what .resx files are, this may not be an article for you.

3 Shared Resources approach

By default, Asp.Net Core 8 MVC technology envisions separate resource file .resx for each controller and the view. But most people do not like it, since most multilanguage strings are the same in different places in the application, we would like it to be all in the same place. Literature [1] calls that approach the “Shared Resources” approach. In order to implement it, we will create a marker class SharedResoureces.cs to group all the resources.
Then in our application, we will invoke Dependency Injection (DI) for that particular class/type instead of a specific controller/view. That is a little trick mentioned in Microsoft documentation [1] that has been a source of confusion in StackOverflow articles [6]. We plan to demystify it here. While everything is explained in [1], what is needed are some practical examples, like the one we provide here.

4 Steps to Multilingual Application

4.1 Configuring Localization Services and Middleware

Localization services are configured in Program.cs:

private static void AddingMultiLanguageSupportServices(WebApplicationBuilder? builder)
{
    if (builder == null) { throw new Exception("builder==null"); };

    builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
    builder.Services.AddMvc()
            .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix);
    builder.Services.Configure<RequestLocalizationOptions>(options =>
    {
        var supportedCultures = new[] { "en", "fr", "de", "it" };
        options.SetDefaultCulture(supportedCultures[0])
            .AddSupportedCultures(supportedCultures)
            .AddSupportedUICultures(supportedCultures);
    });
}

private static void AddingMultiLanguageSupport(WebApplication? app)
{
    app?.UseRequestLocalization();
}

4.2 Create marker class SharedResources.cs

This is just a dummy marker class to group shared resources. We need it for its name and type.
It seems the namespace needs to be the same as the app root namespace, which needs to be the same as the assembly name. I had some problems when changing the namespace, it would not work. If it doesn't work for you, you can try to use the full class name in your DI instruction, like this one:
IStringLocalizer<SharedResources01.SharedResource> StringLocalizer

There is no magic in the name "SharedResource", you can name it "MyResources" and change all references in the code to "MyResources" and all will still work.

The location seems can be any folder, although some articles ([6] claim it needs to be the root project folder I do not see such problems in this example. To me looks like it can be any folder, just keep your namespace tidy.

//SharedResource.cs===================================================
namespace SharedResources01
{
    /*
    * This is just a dummy marker class to group shared resources
    * We need it for its name and type
    * 
    * It seems the namespace needs to be the same as app root namespace
    * which needs to be the same as the assembly name.
    * I had some problems when changing the namespace, it would not work.
    * If it doesn't work for you, you can try to use full class name
    * in your DI instruction, like this one:
    * IStringLocalizer<SharedResources01.SharedResource> StringLocalizer
    * 
    * There is no magic in the name "SharedResource", you can
    * name it "MyResources" and change all references in the code
    * to "MyResources" and all will still work
    * 
    * Location seems can be any folder, although some
    * articles claim it needs to be the root project folder
    * I do not see such problems in this example. 
    * To me looks it can be any folder, just keep your
    * namespace tidy. 
    */

    public class SharedResource
    {
    }
}

4.3 Create language resources files

In the folder “Resources” create your language resources files, and make sure you name them SharedResources.xx.resx.

4.4 Selecting Language/Culture

Based on [5], the Localization service has three default providers:

  1. QueryStringRequestCultureProvider
  2. CookieRequestCultureProvider
  3. AcceptLanguageHeaderRequestCultureProvider

Since most apps will often provide a mechanism to set the culture with the ASP.NET Core culture cookie, we will focus only on that approach in our example.
This is the code to set .AspNetCore.Culture cookie:

private void ChangeLanguage_SetCookie(HttpContext myContext, string? culture)
{
    if(culture == null) { throw new Exception("culture == null"); };

    //this code sets .AspNetCore.Culture cookie
    myContext.Response.Cookies.Append(
        CookieRequestCultureProvider.DefaultCookieName,
        CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
        new CookieOptions { Expires = DateTimeOffset.UtcNow.AddMonths(1) }
    );
}

Cookie can be easily seen with Chrome DevTools:

I built a small application to demo it, and here is the screen where I change the language:

Note that I added some debugging info into the footer, to show the value of the Request language cookie, to see if the app is working as desired.

4.5 Using Localization Services in the Controller

In the controller is of course the Dependency Injection (DI) coming in and filling all the dependencies. The key thing is we are asking for a specific type=SharedResource.
If it doesn't work for you, you can try to use the full class name in your DI instruction, like this one:
IStringLocalizer<SharedResources01.SharedResource> stringLocalizer
Here is the code snippet:

public class HomeController : Controller
{
    private readonly ILogger<HomeController> _logger;
    private readonly IStringLocalizer<SharedResource> _stringLocalizer;
    private readonly IHtmlLocalizer<SharedResource> _htmlLocalizer;

    /* Here is of course the Dependency Injection (DI) coming in and filling 
     * all the dependencies. The key thing is we are asking for a specific 
     * type=SharedResource. 
     * If it doesn't work for you, you can try to use full class name
     * in your DI instruction, like this one:
     * IStringLocalizer<SharedResources01.SharedResource> stringLocalizer
     */
    public HomeController(ILogger<HomeController> logger, 
        IStringLocalizer<SharedResource> stringLocalizer,
        IHtmlLocalizer<SharedResource> htmlLocalizer)
    {
        _logger = logger;
        _stringLocalizer = stringLocalizer;
        _htmlLocalizer = htmlLocalizer;
    }

    //================================

    public IActionResult LocalizationExample(LocalizationExampleViewModel model)
    {
    //so, here we use IStringLocalizer
    model.IStringLocalizerInController = _stringLocalizer["Wellcome"];
    //so, here we use IHtmlLocalizer
    model.IHtmlLocalizerInController = _htmlLocalizer["Wellcome"];
    return View(model);
    }

4.6 Using Localization Services in the View

In the view is of course the Dependency Injection (DI) coming in and filling all the dependencies. The key thing is we are asking for a specific type=SharedResource.
If it doesn't work for you, you can try to use the full class name in your DI instruction, like this one:
IStringLocalizer<SharedResources01.SharedResource> stringLocalizer
Here is the code snippet:

@* LocalizationExample.cshtml ====================================================*@
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Localization

@model LocalizationExampleViewModel

@* Here is of course the Dependency Injection (DI) coming in and filling
all the dependencies. The key thing is we are asking for a specific
type=SharedResource. 
If it doesn't work for you, you can try to use full class name
in your DI instruction, like this one:
@inject IStringLocalizer<SharedResources01.SharedResource> StringLocalizer
 *@

@inject IStringLocalizer<SharedResource> StringLocalizer
@inject IHtmlLocalizer<SharedResource> HtmlLocalizer

@{
    <div style="width:600px">
        <p class="bg-info">
            IStringLocalizer Localized  in Controller: 
            @Model.IStringLocalizerInController
        </p>

        <p class="bg-info">
            @{
                string? text1 = StringLocalizer["Wellcome"];
            }
            IStringLocalizer Localized  in View: @text1
        </p>

        <p class="bg-info">
            IHtmlLocalizer Localized  in Controller: 
            @Model.IHtmlLocalizerInController
        </p>

        <p class="bg-info">
            @{
                string? text2 = "Wellcome";
            }
            IHtmlLocalizer Localized  in View: @HtmlLocalizer[@text2]
        </p>
    </div>
}

4.7 Execution result

Here is what the execution result looks like:

Note that I added some debugging info into the footer, to show the value of the ++Request language cookie++, to see if the app is working as desired.

4.8 Problem with IHtmlLocalizer

I had some problems with IHtmlLocalizer. It resolves strings and translates them, which shows the setup is correct. But, it didn’t work for HTML, as advertised. I tried to translate even simple HTML like “<b>Wellcome</b>”, but it would not work. But it works for simple strings like “Wellcome”.

5 Full Code

Since most people like code they can copy-paste, here is the full code of the application. Code can be downladed at GitHub [99].

//Program.cs===========================================================================
namespace SharedResources01
{
    public class Program
    {
        public static void Main(string[] args)
        {
            //=====Middleware and Services=============================================
            var builder = WebApplication.CreateBuilder(args);

            //adding multi-language support
            AddingMultiLanguageSupportServices(builder);

            // Add services to the container.
            builder.Services.AddControllersWithViews();

            //====App===================================================================
            var app = builder.Build();

            //adding multi-language support
            AddingMultiLanguageSupport(app);

            // Configure the HTTP request pipeline.
            if (!app.Environment.IsDevelopment())
            {
                app.UseExceptionHandler("/Home/Error");
            }
            app.UseStaticFiles();

            app.UseRouting();

            app.UseAuthorization();

            app.MapControllerRoute(
                name: "default",
                pattern: "{controller=Home}/{action=ChangeLanguage}/{id?}");

            app.Run();
        }

        private static void AddingMultiLanguageSupportServices(WebApplicationBuilder? builder)
        {
            if (builder == null) { throw new Exception("builder==null"); };

            builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
            builder.Services.AddMvc()
                    .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix);
            builder.Services.Configure<RequestLocalizationOptions>(options =>
            {
                var supportedCultures = new[] { "en", "fr", "de", "it" };
                options.SetDefaultCulture(supportedCultures[0])
                    .AddSupportedCultures(supportedCultures)
                    .AddSupportedUICultures(supportedCultures);
            });
        }

        private static void AddingMultiLanguageSupport(WebApplication? app)
        {
            app?.UseRequestLocalization();
        }
    }
}

//SharedResource.cs===================================================
namespace SharedResources01
{
    /*
    * This is just a dummy marker class to group shared resources
    * We need it for its name and type
    * 
    * It seems the namespace needs to be the same as app root namespace
    * which needs to be the same as the assembly name.
    * I had some problems when changing the namespace, it would not work.
    * If it doesn't work for you, you can try to use full class name
    * in your DI instruction, like this one:
    * IStringLocalizer<SharedResources01.SharedResource> StringLocalizer
    * 
    * There is no magic in the name "SharedResource", you can
    * name it "MyResources" and change all references in the code
    * to "MyResources" and all will still work
    * 
    * Location seems can be any folder, although some
    * articles claim it needs to be the root project folder
    * I do not see such problems in this example. 
    * To me looks it can be any folder, just keep your
    * namespace tidy. 
    */

    public class SharedResource
    {
    }
}

//HomeController.cs================================================================
namespace SharedResources01.Controllers
{
    public class HomeController : Controller
    {
        private readonly ILogger<HomeController> _logger;
        private readonly IStringLocalizer<SharedResource> _stringLocalizer;
        private readonly IHtmlLocalizer<SharedResource> _htmlLocalizer;

        /* Here is of course the Dependency Injection (DI) coming in and filling 
         * all the dependencies. The key thing is we are asking for a specific 
         * type=SharedResource. 
         * If it doesn't work for you, you can try to use full class name
         * in your DI instruction, like this one:
         * IStringLocalizer<SharedResources01.SharedResource> stringLocalizer
         */
        public HomeController(ILogger<HomeController> logger, 
            IStringLocalizer<SharedResource> stringLocalizer,
            IHtmlLocalizer<SharedResource> htmlLocalizer)
        {
            _logger = logger;
            _stringLocalizer = stringLocalizer;
            _htmlLocalizer = htmlLocalizer;
        }

        public IActionResult ChangeLanguage(ChangeLanguageViewModel model)
        {
            if (model.IsSubmit)
            {
                HttpContext myContext = this.HttpContext;
                ChangeLanguage_SetCookie(myContext, model.SelectedLanguage);
                //doing funny redirect to get new Request Cookie
                //for presentation
                return LocalRedirect("/Home/ChangeLanguage");
            }

            //prepare presentation
            ChangeLanguage_PreparePresentation(model);
            return View(model);
        }

        private void ChangeLanguage_PreparePresentation(ChangeLanguageViewModel model)
        {
            model.ListOfLanguages = new List<SelectListItem>
                        {
                            new SelectListItem
                            {
                                Text = "English",
                                Value = "en"
                            },

                            new SelectListItem
                            {
                                Text = "German",
                                Value = "de",
                            },

                            new SelectListItem
                            {
                                Text = "French",
                                Value = "fr"
                            },

                            new SelectListItem
                            {
                                Text = "Italian",
                                Value = "it"
                            }
                        };
        }

        private void ChangeLanguage_SetCookie(HttpContext myContext, string? culture)
        {
            if(culture == null) { throw new Exception("culture == null"); };

            //this code sets .AspNetCore.Culture cookie
            myContext.Response.Cookies.Append(
                CookieRequestCultureProvider.DefaultCookieName,
                CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
                new CookieOptions { Expires = DateTimeOffset.UtcNow.AddMonths(1) }
            );
        }

        public IActionResult LocalizationExample(LocalizationExampleViewModel model)
        {
            //so, here we use IStringLocalizer
            model.IStringLocalizerInController = _stringLocalizer["Wellcome"];
            //so, here we use IHtmlLocalizer
            model.IHtmlLocalizerInController = _htmlLocalizer["Wellcome"];
            return View(model);
        }

        public IActionResult Error()
        {
            return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
        }
    }
}

//ChangeLanguageViewModel.cs=====================================================
namespace SharedResources01.Models.Home
{
    public class ChangeLanguageViewModel
    {
        //model
        public string? SelectedLanguage { get; set; } = "en";

        public bool IsSubmit { get; set; } = false;

        //view model
        public List<SelectListItem>? ListOfLanguages { get; set; }
    }
}

//LocalizationExampleViewModel.cs===============================================
namespace SharedResources01.Models.Home
{
    public class LocalizationExampleViewModel
    {
        public string? IStringLocalizerInController { get; set; }
        public LocalizedHtmlString? IHtmlLocalizerInController { get; set; }
    }
}
@* ChangeLanguage.cshtml ===================================================*@
@model ChangeLanguageViewModel

@{
    <div style="width:500px">
        <p class="bg-info">
            <partial name="_Debug.AspNetCore.CultureCookie" /><br />
        </p>

        <form id="form1">
            <fieldset class="border rounded-3 p-3">
                <legend class="float-none w-auto px-3">Change Language</legend>
                <div class="form-group">
                    <label asp-for="SelectedLanguage">Select Language</label>
                    <select class="form-select" asp-for="SelectedLanguage"
                            asp-items="@Model.ListOfLanguages">
                    </select>
                    <input type="hidden" name="IsSubmit" value="true">
                    <button type="submit" form="form1" class="btn btn-primary mt-3 float-end"
                            asp-area="" asp-controller="Home" asp-action="ChangeLanguage">
                        Submit
                    </button>
                </div>
            </fieldset>
        </form>
    </div>
}

@* LocalizationExample.cshtml ====================================================*@
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Localization

@model LocalizationExampleViewModel

@* Here is of course the Dependency Injection (DI) coming in and filling
all the dependencies. The key thing is we are asking for a specific
type=SharedResource. 
If it doesn't work for you, you can try to use full class name
in your DI instruction, like this one:
@inject IStringLocalizer<SharedResources01.SharedResource> StringLocalizer
 *@

@inject IStringLocalizer<SharedResource> StringLocalizer
@inject IHtmlLocalizer<SharedResource> HtmlLocalizer

@{
    <div style="width:600px">
        <p class="bg-info">
            IStringLocalizer Localized  in Controller: 
            @Model.IStringLocalizerInController
        </p>

        <p class="bg-info">
            @{
                string? text1 = StringLocalizer["Wellcome"];
            }
            IStringLocalizer Localized  in View: @text1
        </p>

        <p class="bg-info">
            IHtmlLocalizer Localized  in Controller: 
            @Model.IHtmlLocalizerInController
        </p>

        <p class="bg-info">
            @{
                string? text2 = "Wellcome";
            }
            IHtmlLocalizer Localized  in View: @HtmlLocalizer[@text2]
        </p>
    </div>
}

6 References

[1] https://learn.microsoft.com/en-us/aspnet/core/fundamentals/localization/make-content-localizable?view=aspnetcore-8.0
Make an ASP.NET Core app's content localizable

[2] https://learn.microsoft.com/en-us/aspnet/core/fundamentals/localization/provide-resources?view=aspnetcore-8.0
Provide localized resources for languages and cultures in an ASP.NET Core app

[3] https://learn.microsoft.com/en-us/aspnet/core/fundamentals/localization/select-language-culture?view=aspnetcore-8.0
Implement a strategy to select the language/culture for each request in a localized ASP.NET Core app

[4] https://learn.microsoft.com/en-us/aspnet/core/fundamentals/localization?view=aspnetcore-8.0
Globalization and localization in ASP.NET Core

[5] https://learn.microsoft.com/en-us/aspnet/core/fundamentals/troubleshoot-aspnet-core-localization?view=aspnetcore-8.0
Troubleshoot ASP.NET Core Localization

[6] https://stackoverflow.com/questions/42647384/asp-net-core-localization-with-help-of-sharedresources
ASP.NET Core Localization with help of SharedResources

[99] https://github.com/MarkPelf/AspNet8MultilingualApplicationWithSingleResxFile