AJ's blog

November 30, 2013

ASP.NET MVC I18n – Part 3: Custom Language Choice

Filed under: .NET, .NET Framework, ASP.NET MVC, HTML5, Internationalization — ajdotnet @ 5:49 pm

The last post showed how we could honor the language preferences the user declared in his browser. However, it is also good practice to let the user change the region, independent of the browser settings.

Note: This post is part of a series

To achieve this, we need:

  • a way to maintain the user’s culture choice overriding the browser preferences
  • a way to actually change the preferred culture

 

Maintaining the preferred culture… 

As we know, each request comes with the accept-language header, telling us language preferences set in the browser. We need something similar to maintain the overriding culture choice.

One option, that is used very often used, is including the region in the URL. This implies addressing the culture in every route declaration, and every action link needs to provide the culture as additional parameter.  Alex shows how the URLs can be analyzed and the culture applied using a custom MvcRouteHandler; Nadeem does it in a Controller base class with similar intentions. Something in the same line should be possible for generating URLs (e.g. with a custom route class).

This certainly works and there is nothing wrong with it. Still, I don’t like this approach all that much. From a technical perspective, URLs in static content like CSS files are still not addressed properly. Additionally there are also other MVC features affecting URLs as well, e.g. areas, which might cause problems. And from a more “semantic” perspective, URLs are meant to address "resources" – which is a hard enough task already. Language preferences, similarly to skin and other profile data, is kind of "orthogonal" information affecting the rendering, but not addressing the resource. Unfortunately MVC does not really give an answer on how to pass that kind of information, which is why you can find all possible approaches (URL parts, query params, cookies, user session, …), none of which is always good or always bad.

For the problem at hand, the preferred culture, my choice is using a cookie: Adjusting the URL feels too invasive, query params are too error prone, and no need to enforce a session.

 

Setting the culture in a cookie is quite simple:

const string CookieName = "PreferredCulture";

public static void SetPreferredCulture(this HttpResponseBase response, string cultureName)
{
    SetPreferredCulture(response.Cookies, cultureName);
}

static void SetPreferredCulture(HttpCookieCollection cookies, string cultureName)
{
    var cookie = new HttpCookie(CookieName, cultureName);
    cookie.Expires = DateTime.Now.AddDays(30);
    cookies.Set(cookie);
    Debug.WriteLine("SetPreferredCulture: " + cultureName);
}

Reading likewise:

static CultureInfo GetPreferredCulture(HttpCookieCollection cookies)
{
    var cookie = cookies[CookieName];
    if (cookie == null)
        return null;
    var culture = GetCultureInfo((string)cookie.Value);
    if (culture == null)
        return null;
    if (!SupportedCultures.Where(ci => ci.Name == culture.Name).Any())
        return null;
    return culture;
}

I don’t trust the cookie to contain a valid value. Call me paranoid, but somebody could have tampered with the request.

And we need to change our method to determine the current culture based on the cookie value:

public static void ApplyUserCulture(this HttpRequest request)
{
    ApplyUserCulture(request.Headers, request.Cookies);
}

static void ApplyUserCulture(NameValueCollection headers, HttpCookieCollection cookies)
{
    var culture = GetPreferredCulture(cookies)
        ?? GetUserCulture(headers)
        ?? SupportedCultures[0];
    
    var t = Thread.CurrentThread;
    t.CurrentCulture = culture;
    t.CurrentUICulture = culture;
    Debug.WriteLine("Culture: " + culture.Name);
}

 

Changing the preferred culture…

Now that we have the groundwork in place, we need to surface the feature to the user. Starting MVC-like with a controller and action to set the preferred culture:

public class CultureController : Controller
{
    //
    // GET: /SetPreferredCulture/de-DE
    public ActionResult SetPreferredCulture(string culture, string returnUrl)
    {
        this.Response.SetPreferredCulture(culture);
        if (string.IsNullOrEmpty(returnUrl))
            return RedirectToAction("Index", "Home");
        return Redirect(returnUrl);
    }
}

And the necessary route…

var route = routes.MapRoute(
    name: "SetPreferredCulture",
    url: "SetPreferredCulture/{culture}",
    defaults: new { controller = "Culture", action = "SetPreferredCulture", culture = UrlParameter.Optional }
);

For the UI it’s really up to you how you present the choice: some drop down list is quite common. I intend to provide a single action link that simply choses the next supported culture, thus repeatedly clicking it would cycle through all cultures. For this a little helper method makes life simpler:

public static void GetSwitchCultures(out CultureInfo currentCulture, out CultureInfo nextCulture)
{
    currentCulture = Thread.CurrentThread.CurrentUICulture;
    var currentIndex = Array.IndexOf(SupportedCultures.Select(ci => ci.Name).ToArray(), currentCulture.Name);
    int nextIndex = (currentIndex + 1) % SupportedCultures.Length;
    nextCulture = SupportedCultures[nextIndex];
}

Based on the work so far I can implement a _SetPreferredCulture.cshtml partial view:

@{
    // cycle through supported cultures
    System.Globalization.CultureInfo currentCulture;
    System.Globalization.CultureInfo nextCulture; 
    MyStocks.Mvc.Helper.CultureHelper.GetSwitchCultures(out currentCulture, out nextCulture);
    string currentCultureDisplayName = currentCulture.Parent.NativeName;
    string nextCultureDisplayName = nextCulture.Parent.NativeName;
    string linkText = currentCultureDisplayName + " => "+ nextCultureDisplayName; 
    string url= Url.Action("SetPreferredCulture", "Culture", new { culture = nextCulture.Name, returnUrl = Request.RawUrl });
}
<div>
    @Html.ActionLink(linkText, "SetPreferredCulture", "Culture", new { culture = nextCulture.Name, returnUrl = Request.RawUrl }, null)
</div>

Include it in the _Layout.cshtml view and my users can change their culture as they wish:

Done.

 

That’s all for now folks,
AJ.NET

November 23, 2013

ASP.NET MVC I18n – Part 2: Detect Browser Settings

Filed under: .NET, .NET Framework, ASP.NET MVC, HTML5, Internationalization — ajdotnet @ 5:47 pm

If you want to cater to the user’s language and region choice, the first thing to do is actually take note of his choice.

Note: This post is part of a series

While you could simply start with one default language and let him switch the language using some control, it is probably far more "polite" to use the fact that he already tells you his preferences: Each browser has the ability to let the user chose his is language preferences:

This information is sent with each request via the accept-language http header, which contains a collection of weighted language tags, say the following header with the above settings:

Accept-Language: de-DE,de;q=0.8,en-US;q=0.5,en;q=0.3

This is very basic stuff and I’m only going into this detail because I have actually seen code that uses the header value as if it contains exactly one language and ignoring the weight.

The simple approach…

“All” we have to do is grab the language from the header and tell .NET for each request:

protected void Application_OnBeginRequest()
{
    CultureHelper.ApplyUserCulture(this.Request);
}

The CultureHelper class contains the plumbing, we’re going to set up:

public static void ApplyUserCulture(this HttpRequest request)
{
    ApplyUserCulture(request.Headers);
}

static void ApplyUserCulture(NameValueCollection headers)
{
    var culture = GetUserCulture(headers) ?? CultureInfo.GetCultureInfo("en-US");
    
    var t = Thread.CurrentThread;
    t.CurrentCulture = culture;
    t.CurrentUICulture = culture;
    Debug.WriteLine("Culture: " + culture.Name);
}

Actually determining the user’s choice is simple: 

static CultureInfo GetUserCulture(NameValueCollection headers)
{
    // Accept-Language: de, en-US;q=0.8
    string acceptLanguages = headers["Accept-Language"];
    if (string.IsNullOrEmpty(acceptLanguages))
        return null;
    // split languages _and_ weights
    var cultureName = acceptLanguages.Split(‘,’, ‘;’)[0];
    return GetCultureInfo(cultureName);
}

private static CultureInfo GetCultureInfo(string language)
{
    try
    {
        return CultureInfo.GetCultureInfo(language);
    }
    catch (CultureNotFoundException)
    {
        return null;
    }
}

Note the exception handling. This is necessary due to the different notions of "language" (see the last post), and the possibility that .NET might not support Klingon. Most people will never actually encounter this issue during regular operations, but there is the occasional exception (no pun intended). And of course the possibility of malicious requests…

Accessing the header value and setting the cultures is something that can be found in every blog post and tutorial. Actually ASP.NET would do this automatically (see also Scott’s post).

Unfortunately this is simply too shortsighted.

 

Matching the user’s language choice…

I don’t want the user’s first language choice applied unconditionally. I want the user’s language choices that I can best support with my supported cultures.

My application is going to support English and German. What if my French colleague showed up? We could set the culture to “fr-FR” (as, in fact, the above code will do). Since there are no French localizations, proper fallback strategy should ensure he gets English texts. Is this really the best choice, if he asks for “fr-FR,de-DE,en-US”? And what about date formats? Is the meaning of 01/06/2013 immediately obvious? Does he even suspect, that – within an otherwise English UI – this is actually the first of June (interpretation as dd/mm/yyyy) – not epiphany (mm/dd/yyyy)?
Thus it is better to present him a consistent (in this case American English, i.e. en-US) version, rather than a confusing mix.

So, what I want is matching the users’ list of preferred regions (all of the header values, not just the first one!) to the collection of regions my application supports, and set the culture to the best match.

This is something I have rarely seen addressed, much less correctly. E.g. Nadeem is one of the few who actually recognized that necessity; still, his implementation does not quite meet my expectations.

 

Let’s make that crystal clear: Say my application supports en-US and de-DE, with en-US the default.

Perfect matches:

  • If the user’s request is "de-DE, en-US;q=0.8", then de-DE is the perfect match. (Obviously.) Likewise, if the user’s request is "fr-FR, en-US;q=0.8", then en-US is the perfect match. Not the users’ first choice, but anyway. Also if the user’s request is "fr-FR, de-DE;q=0.8, en-US;q=0.5", then de-DE is the perfect match. This is a case which most, if not all implementations I have seen, are missing.
  • Something that is also missed quite often: If his request is "de, en-US;q=0.8", then de-DE is still the correct choice, because de encompasses all German regions.
  • If his request is "de-AT, en-US;q=0.8", then en-US is the best choice, because it’s a perfect match, while de-AT matches de-DE only partly. This is debatable, but since he could control that via the browser settings, it’s better than other interpretations.

Partial matches:

  • If his request is "de-AT, fr-FR;q=0.8", then we have no perfect match. But de-DE matches de-AT at least partly, so it would be a better choice, than using the fallback to the default region en-US.

No match:

  • Only if no requested region matches any of the supported cultures even slightly, e.g. "es-ES, fr-FR;q=0.8", the default region en-US is used – nothing else that could be done.

Now, this requires a little more code than before…

 

Parsing the header…

The Accept-Language header generally contains entries with weights (usually they are sorted according to weight anyway, but I don’t like to rely on that). Parsing it is relatively straightforward, but requires some groundwork:

public static CultureInfo[] GetUserCultures(string acceptLanguage)
{
    // Accept-Language: fr-FR , en;q=0.8 , en-us;q=0.5 , de;q=0.3
    if (string.IsNullOrWhiteSpace(acceptLanguage))
        return new CultureInfo[] { };
    
    var cultures = acceptLanguage
        .Split(‘,’)
        .Select(s => WeightedLanguage.Parse(s))
        .OrderByDescending(w => w.Weight)
         .Select(w => GetCultureInfo(w.Language))
         .Where(ci => ci != null)
         .ToArray();
    return cultures;
}

WeightedLanguage is a little helper class:

class WeightedLanguage
{
    public string Language { get; set; }
    public double Weight { get; set; }
    
    public static WeightedLanguage Parse(string weightedLanguageString)
    {
        // de
        // en;q=0.8
        var parts = weightedLanguageString.Split(‘;’);
        var result = new WeightedLanguage { Language = parts[0].Trim(), Weight = 1.0 };
        if (parts.Length > 1)
        {
            parts[1] = parts[1].Replace("q=", "").Trim();
            double d;
            if (double.TryParse(parts[1], NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out d))
                result.Weight = d;
        }
        return result;
    }
}

Maintaining supported cultures…

Getting the supported cultures can be done via some configuration… – or just be hardcoded. So let’s just assume we have the following array, and we will use first culture as default culture:

public static readonly CultureInfo[] SupportedCultures = new CultureInfo[]
{
    CultureInfo.GetCultureInfo("en-US"),
    CultureInfo.GetCultureInfo("de-DE"),
    //CultureInfo.GetCultureInfo("fr-FR"),
    //CultureInfo.GetCultureInfo("es-ES"),
};

Matching…

Now we can start matching the user’s cultures and the supported cultures.

public static CultureInfo GetUserCulture(NameValueCollection headers)
{
    var acceptedCultures = GetUserCultures(headers["Accept-Language"]);
    var culture = GetMatchingCulture(acceptedCultures, SupportedCultures);
    return culture;
}

Actually matching requires two passes. The first pass is looking for the perfect match; if none is found, the second pass looks for an imperfect match:

public static CultureInfo GetMatchingCulture(CultureInfo[] acceptedCultures, CultureInfo[] supportedCultures)
{
    return
        // first pass: exact matches as well as requested neutral matching supported region
        // supported: en-US, de-DE
        // requested: de, en-US;q=0.8
        // => de-DE! (de has precendence over en-US)
        GetMatch(acceptedCultures, supportedCultures, MatchesCompletely)
        // second pass: look for requested neutral matching supported _neutral_ region
        // supported: en-US, de-DE
        // requested: de-AT, en-GB;q=0.8
        // => de-DE! (no exact match, but de-AT has better fit than en-GB)
        ?? GetMatch(acceptedCultures, supportedCultures, MatchesPartly);
}

GetMatch traverses the user’s accepted cultures, and finds the first matching supported culture:

public static CultureInfo GetMatch(
    CultureInfo[] acceptedCultures, 
    CultureInfo[] supportedCultures,
    Func<CultureInfo, CultureInfo, bool> predicate)
{
    foreach (var acceptedCulture in acceptedCultures)
    {
        var match = supportedCultures
            .Where(supportedCulture => predicate(acceptedCulture, supportedCulture))
            .FirstOrDefault();
        if (match != null)
            return match;
    }
    return null;
}

The predicates do the actual match for a pair of cultures. For the perfect match this includes a requested neutral culture matching a region, see the second example above.

static bool MatchesCompletely(CultureInfo acceptedCulture, CultureInfo supportedCulture)
{
    if (supportedCulture.Name == acceptedCulture.Name)
        return true;
    // acceptedCulture could be neutral and supportedCulture specific, but this is still a match (de matches de-DE, de-AT, …)
    if (acceptedCulture.IsNeutralCulture)
    {
        if (supportedCulture.Parent.Name == acceptedCulture.Name)
            return true;
    }
    return false;
}

For an imperfect match, we simply compare the neutral cultures (i.e. the parents of specific cultures):

static bool MatchesPartly(CultureInfo acceptedCulture, CultureInfo supportedCulture)
{
    supportedCulture = supportedCulture.Parent;
    if (!acceptedCulture.IsNeutralCulture)
        acceptedCulture = acceptedCulture.Parent;
    
    if (supportedCulture.Name == acceptedCulture.Name)
        return true;
    return false;
}

Finally.

You may want to print the culture on the home view:

Current culture is: @UICulture

UICulture is a shortcut available from the view base class.

Quite a bunch of code one has to write himself for a regular demand, if you ask me.

That’s all for now folks,
AJ.NET

November 15, 2013

ASP.NET MVC I18n – Part 1: Basics

Filed under: .NET, .NET Framework, ASP.NET MVC, HTML5, Internationalization — ajdotnet @ 9:15 pm

When I started the series, I didn’t even plan this particular post. But as I drafted the upcoming posts, I realized, we cannot avoid taking a look at what we are actually dealing with. One should know, what he is talking about. Thus, here’s the necessary theoretical background…

The one thing we need to be aware is that we are dealing with two different contexts: The server part, defined by the .NET Framework, and the client side, defined by HTML et al. These contexts differ in the terms they use, in their customs, and in their technical scope.

Server side…

.NET maintains the necessary information via the CultureInfo class:

“The CultureInfo class specifies a unique name for each culture, based on RFC 4646. The name is a combination of an ISO 639 two-letter lowercase culture code associated with a language and an ISO 3166 two-letter uppercase subculture code associated with a country or region.”

In short, we are talking about "en-US", "de-DE", and so on (ignoring special cases). One distinction is made regarding neutral cultures (associated only with the first part, e.g. "en" and "de"), and specific cultures, associated with the country or region. Still, neutral cultures are still maintained in CultureInfo instances, including information beyond the language. They generally rely on the "major representative" of that language. i.e. Germany for German (sorry Austrians ;-)), and the United Kingdom of Great Britain and Northern Irland  … no wait… that former colony of theirs Zwinkerndes Smiley, for English.

It should be noted, that CultureInfo deals with all aspects regarding regions: It acts as language selector, provides date and time formats, even the calendar is addressed.

Regarding localized content (like strings for labels), .NET uses a system of resources and satellite assemblies, that are accessed via the ResourceManager class, either directly or through generated code. (I will assume that this is basic .NET knowledge and not go into further details about it.)

All in all, a comprehensive and consistent system.

Client side…

HTML traditionally only addresses languages (not date or number formats):

“The lang attribute’s value is a language code that identifies a natural language spoken, written, or otherwise used for the communication of information among people.”

http://www.w3.org/TR/REC-html40/struct/dirlang.html#h-8.1.1 

HTML is also far more open in regard to how a language is identified. This includes, but goes beyond what .NET supports:

“Here are some sample language codes:

  • "en": English
  • "en-US": the U.S. version of English.
  • "en-cockney": the Cockney version of English.
  • "i-navajo": the Navajo language spoken by some Native Americans.
  • "x-klingon": The primary tag "x" indicates an experimental language tag”

http://www.w3.org/TR/REC-html40/struct/dirlang.html#h-8.1.1 

However, the focus of HTML is also limited to languages, to the point of actively ignoring any other localization demand:

“The golden rule when creating language tags is to keep the tag as short as possible. Avoid region, script or other subtags except where they add useful distinguishing information. For instance, use ja for Japanese and not ja-JP, unless there is a particular reason that you need to say that this is Japanese as spoken in Japan, rather than elsewhere.”

http://www.w3.org/International/articles/language-tags/

And, indeed, you’ll find that most localized HTML or CSS code you may come across (in samples and documentation) uses two-letter language codes.

Alas, with HTML5 and the new input controls, the focus on "language" is no longer sufficient. A date picker does not only change the weekday names, but also the date format and the first day of the week. The way this issue is addressed by the W3C however seems a bit helpless and places the issue on the browser vendors:

“Browsers are encouraged to use user interfaces that present dates, times, and numbers according to the conventions of either the locale implied by the input element’s language or the user’s preferred locale.”

http://www.w3.org/TR/html5/forms.html#input-impl-notes

Well, a little further down they are refreshingly honest:

“There’s still a risk that the user would end up arriving a month late, of course, but there’s only so much that can be done about such cultural differences…”

BTW: Whenever I wrote HTML, this also included XML, CSS, and HTTP (here and here).

Regarding localized content, HTML only allows denoting the language by the lang attribute. You can mix different languages in one document, but HTML itself does not do anything further. CSS selectors on the other hand can be used to attach styles depending on the language. 

 

Consequences?

For an LOB application, using "language" in the limited sense of HTML is far to shortsighted, thus I will use regions (respective specific cultures on the server, respective tags with language code and country or region), whenever possible. This may seem odd in HTML or CSS, but so what?

And the next post will contain some code, promise.

That’s all for now folks,
AJ.NET

November 8, 2013

ASP.NET MVC Internationalization

Filed under: ASP.NET MVC, Internationalization — ajdotnet @ 8:13 am

Internationalization and localization of web applications is a regular demand and by no means something exotic or rarely required. Speaking specifically of Line-of-Business (LOB) applications built on ASP.NET MVC there is even plenty of information available, e.g starting here or simply by issuing a bing search.

Actually doing localization in my web application proved to be… . Well, not as simple as some blog posts and tutorials try to sell it.

The problem is not that the information is not there, or that it is overly complex. It’s just rather fragmented, and even the well documented aspects often miss some crucial details. What I could not find is a complete guide to all aspects of localizing my web application.

Well, luckily for you, this is what I’m trying to accomplish here…

To get it out of the way: “Internationalization” (a.k.a. i18n) provides the basic plumbing to be able to support different languages and regions, while “localization” provides the necessary information for a particular language and region. Two sides of the same coin, actually.

Further details can be found here.

Here is what it takes…

TOC

 

Playing field

For this endeavor let’s provide our playing field: I have a simple business contract for maintaining some stock information, and a (simplistic) implementation to maintain the data in a database. Here’s the contract:

[DebuggerDisplay("BusinessContract.Stock: {ID} {Isin} {Name} {Price} {Date}")]
public class Stock
{
    public int ID { get; set; }
    /// <summary>
    /// http://en.wikipedia.org/wiki/International_Securities_Identification_Number  
    /// 12-character alpha-numerical code
    /// </summary>
    public string Isin { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public DateTime Date { get; set; }
}

public interface IStockService
{
    Stock[] GetAllStocks();
    Stock GetStock(int id);
    void UpdateStock(Stock stock);
}

With that in mind, creating an MVC4 intranet application, with respective controller actions is quite simple, and offering a simple list view, details view, and an edit view is just point-and-click using the "new controller/view" features in Visual Studio. Add some styling and it looks like this:

Initial Application

Then I realize: I am actually German, and I would like to be greeted as such – and  please not in some halfhearted fashion, I want every aspect to be addressed: Labels, data formats, and so on.

We’ll get there, Next couple of post…

That’s all for now folks,
AJ.NET

November 3, 2013

MVC is a UI pattern…

Filed under: ASP.NET, ASP.NET MVC, Software Architecture — ajdotnet @ 3:01 pm

Recently we had some discussions about how the MVC pattern of an ASP.NET MVC application fits into the application architecture.

Clarification: This post discusses the MVC pattern as utilized in ASP.NET MVC. Transferring these statements to other web frameworks (e.g. in the Java world) might work. But then it might not. However, transferring them to the client side architecture of JavaScript based applications (using knockout.js etc.), or even to rich client applications, is certainly ill-advised!

 

One trigger was Conrad’s post "MVC is dead, it’s time to MOVE on.", where her claims

"the problem with MVC as given is that you end up stuffing too much code into your controllers, because you don’t know where else to put it.”

http://cirw.in/blog/time-to-move-on.html (emphasis by me)

The other trigger was "Best Practices for ASP.NET MVC", actually from a Microsoft employee:

"Model Recommendations
The model is where the domain-specific objects are defined. These definitions should include business logic (how objects behave and relate), validation logic (what is a valid value for a given object), data logic (how data objects are persisted) and session logic (tracking user state for the application)."

"DO put all business logic in the model.
If you put all business logic in the model, you shield the view and controller from making business decisions concerning data."

http://blogs.msdn.com/b/aspnetue/archive/2010/09/17/second_2d00_post.aspx

Similar advice can be found abundantly.

 

I have a hard time accepting these statements and recommendations, because I think they are plainly wrong. (I mean no offense, really, it’s just an opinion.)

These statements seem to be driven by the idea, that the MVC pattern drives the whole application architecture. Which is not the case!
The MVC pattern – especially in web applications, certainly in ASP.NET MVC – is a UI pattern, i.e. it belongs to the presentation layer.

Note: I’m talking about the classical, boilerplate 3-layer-architecture for web applications

In terms of a classical layer architecture:

This is how it works:

  • The model encapsulates the data for the presentation layer. This may include validation information (e.g. attributes) and logic, as far as related to validation on the UI. Also I generally prefer value objects (as opposed to business objects, see also here).
  • The controller receives the incoming request, delegates control to the respective business services, determines the next logical step, collects the necessary data (again from the business services), and hands it off to the view. In this process it also establishes the page flow.
    In other words: The controller orchestrates the control flow, but delegates the single steps.

 

Coming back to the original citations…

  • If "you end up stuffing too much code into your controllers", then the problem is not MVC, not the fact that controllers by design (or inherent deficiencies of the pattern) accumulate too much code. It’s far more likely that the controller does things it’s not supposed to do. E.g. do business validations or talk directly to the database. (Or, frankly, “you don’t know where else to put it” is the operative phrase.)
  • If your model "is where the domain-specific objects are defined", and you "put all business logic in the model", in order to "shield the view and controller from making business decisions concerning data", then these statements overlook the fact that domain-specific objects and business logic are artifacts of the business layer.

Fortunately you can find this advice elsewhere (I’m not alone after all!):

"In the MVC world the controller is simply a means of getting a model from the view to your business layer and vice-versa. The aim is to have as little code here as is possible."
http://stackoverflow.com/questions/2128116/asp-net-mvc-data-model-best-practices-for-a-newb

It’s just harder to come by.

That’s all for now folks,
AJ.NET

Create a free website or blog at WordPress.com.