.NET MVC SEO/Slug Only URLs for eCommerce

As part of my ongoing journey creating an eCommerce site I had to implement into the .NET MVC 3 website I’m creating a way of having URLs such as

website.com/pretty-pink-boots

Instead of:

website.com/product/view/100/pretty-pink-boots

The theory being that the URL is shorter and more SEO friendly, as it contains just the important information and the full URL is shown in Google. The keen eyed amongst you will realize that this means we have no “unique ID” or controller specified in the URL - so how do we know whether it’s a product and which product to route to? Possible solutions could be:

  • Have a route at the bottom of the list of routes of simply {id} and route it to the ProductController.
    The problem here is that the slug is not the unique identifier for a product so I would have to change the product ID in the website to ‘pretty-pink-boots’ or have two unique IDs. The other problem is that we could only have the slug only URL to products, what about categories and content pages?
  • Have a route at the bottom of the list of routes of simply {id} again but this time route towards an SEOController. This controller would then do a RedirectToAction to the correct controller (Product or Category).
    Here we now have the ability to have slug only URLs for different areas of the site but the redirect causes a HTTP redirect and we definitely don’t want that from an SEO point of view. We still have the unique ID problem.

So my solution was as follows:

  1. Create a controller factory that inherits from the DefaultControllerFactory
  2. Override the GetControllerInstance function and put in some logic that if the controller requested is of type SEOController we will change the route data to (for example) “/Product/View/100” and return that controller instance.
  3. Tell MVC to use the new controller
  4. Build in a URLManager that takes in the slug and returns whether it’s a valid URL, and if so, the controller type and unique ID
  5. If no slug exists for the requested URL then a 404 must be returned
  6. Finally a route needs setting up for the SEOController, but the route must be as strict as possible and not “take over” from other URLs.

So here’s the code. First the Controller Factory

Imports makit.WebUI.Controllers
Imports System.Globalization
Imports makit.Core.URLs

Namespace Infrastructure

    Public Class MakitControllerFactory
        Inherits DefaultControllerFactory

        Protected Overrides Function GetControllerInstance(ByVal requestContext As RequestContext, ByVal controllerType As Type) As IController

            If controllerType Is Nothing Then
                Throw New HttpException(404, String.Format("The controller for path '{0}' was not found or does not implement IController", requestContext.HttpContext.Request.Path))
            End If

            If Not GetType(IController).IsAssignableFrom(controllerType) Then
                Throw New ArgumentException(String.Format("The controller type '{0}' must implement IController.", controllerType), "controllerType")
            End If

            ' This code is the custom bit to handle slug only URLs'
            If controllerType Is GetType(SEOController) Then

                ' Check if it is an actual slug and get the product/category if it is'
                Dim slug As URLManager.SlugRewrite = URLManager.getURLSlugRewrite(requestContext.RouteData.Values("id").ToString)

                If slug.exists Then

                    ' Rewrite the routedata to point to the actual controller and id'
                    requestContext.RouteData.Values("controller") = slug.pageType
                    requestContext.RouteData.Values("action") = "Display"
                    requestContext.RouteData.Values("id") = slug.pageID

                    Select Case slug.pageType
                        Case "Category"
                            controllerType = GetType(CategoryController)
                        Case "ContentPage"
                            controllerType = GetType(ContentPageController)
                        Case "Product"
                            controllerType = GetType(ProductController)
                    End Select

                    Return MyBase.GetControllerInstance(requestContext, controllerType)

                Else

                    ' A normal 404 because not a slug url'
                    Throw New HttpException(404, String.Format("No slug exists for '{0}'", requestContext.HttpContext.Request.Path))

                End If

            Else

                ' Not slug only so default to the base method'
                Return MyBase.GetControllerInstance(requestContext, controllerType)

            End If

        End Function

    End Class

End Namespace

The SEOController just needs to exist:

Namespace Controllers

    Public Class SEOController
        Inherits System.Web.Mvc.Controller

    End Class

End Namespace

Global.asax changes:

Imports makit.Core.Infrastructure
Imports makit.WebUI.Infrastructure

Public Class MvcApplication
    Inherits System.Web.HttpApplication

    Shared Sub RegisterGlobalFilters(ByVal filters As GlobalFilterCollection)
        filters.Add(New HandleErrorAttribute())
    End Sub

    Shared Sub RegisterRoutes(ByVal routes As RouteCollection)
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}")

        routes.MapRoute("Default",
            "{controller}/{action}/{id}",
            New With {.action = "Index",
                      .id = UrlParameter.Optional}
        )

        ' Below now handles all that didnt match as a clean SEO URL'
        routes.MapRoute("SEO Slug URLs",
            "{id}",
            New With {.controller = "SEO", .action = "Index"},
            New With {.id = "^[A-Za-z0-9\-]+$"}
        )

        ' Below now handles all that didnt match as a clean SEO URL'
        routes.MapRoute("Home Page",
            "",
            New With {.controller = "Home", .action = "Index"}
        )

    End Sub

    Sub Application_Start()

        AreaRegistration.RegisterAllAreas()
        RegisterGlobalFilters(GlobalFilters.Filters)

        RegisterRoutes(RouteTable.Routes)

        ' Custom factory which handles slug only URLs'
        ControllerBuilder.Current.SetControllerFactory(New MakitControllerFactory())

        'TODO: Below is temp for testing'
        'RouteDebug.RouteDebugger.RewriteRoutesForTesting(RouteTable.Routes)'

    End Sub

End Class

Finally the URLManager, here is just an example stub, this will most likely be programmed with a Dictionary object in memory containing all the slugs as keys and the SlugRewrite structures as the values

Namespace URLs

    Public Class URLManager

        Public Structure SlugRewrite
            Dim exists As Boolean
            Dim pageType As String
            Dim pageID As String
        End Structure

        Public Shared Function getURLSlugRewrite(ByVal slug As String) As SlugRewrite

            Dim slugReturn As New SlugRewrite

            Select Case slug
                Case "pencils"
                    slugReturn.exists = True
                    slugReturn.pageType = "Category"
                    slugReturn.pageID = "35"
                Case "pretty-pink-boots"
                    slugReturn.exists = True
                    slugReturn.pageType = "Product"
                    slugReturn.pageID = "2936"
            End Select

            Return slugReturn

        End Function

    End Class

End Namespace

Several things to note:

  • When building the site you will also need a reverse lookup to the slugs, in my head I will be having a slug table in the DB like the following:

    Slug - “pencils”
    Type - “Category”
    ID “35” 

    But also have the slug in the product table as a column so we have a lookup from both ways.

  • The links in the site will then need to link through to the page using the slug if one exists, which can now be gotten from the product table. If no slug exists it can then default to the Product/View/100 style URL.

  • Finally, the routing I’ve done will only route with URLs not matching any other route and a URL containing only alpha numeric characters and a dash - even so the URLManager could get a lot of hits if spiders are trying lots of types of URLs so the slug checking needs to be cached and high performance - no straight to DB calls. 
.NET Dependency Injection

Introduction

Recently I’ve been started a new MVC 3 app which is to have lot’s of components so I decided to go down the Dependency Injection route. This, for those who are hazy, is a OO design pattern for decoupling components.

I did some research (read, Google) and thought I’d go with StructureMap, but due to my limitations at work I must use VB.NET and I couldn’t find many examples, even converting from C# using my brain and auto tools, so I had some issues getting it working exactly how I wanted it to. Therefore I decided to try Ninject. The differences aren’t much and both do the DI I wanted to do but Ninject I got working in less than 10 minutes.

The app I’m working on is a MVC3 e-Commerce app with several distinct areas, such as categories, products, content pages, etc. Using the repository pattern I have repositories for returning the models but the actual concrete implementation of this can be done however I want and Ninject takes care of returning the correct concrete implementation. This means the application can be built with simple “fake/dummy” implementations and the whole site can be designed without ever needing to do the actual behind the scene complexities.

I will also use Ninject for returning concrete implementations of logging functionality, accounts, baskets, etc.

Simple “How I Did It”

The thing that frustrated me was trying to find a good example that was simple and easy to reimplement for myself so hopefully this will help for those with similar software architecture:

  1. Download Ninject - http://ninject.org/
  2. Add a reference to the MVC Web Project for Ninject.dll
  3. Create a class inheriting from the default MVC controller factory
    Imports System.Configuration
    Imports System.Web.Mvc
    Imports System.Web.Routing
    Imports Ninject
    Imports Ninject.Modules
    Imports makit.Core.Repositories
    
    Namespace Infrastructure
    
        Public Class DIControllerFactory
            Inherits DefaultControllerFactory
    
            Private kernel As IKernel = New StandardKernel(New DIServices())
    
            ' MVC calls this to get the controller for each request'
            Protected Overrides Function GetControllerInstance(ByVal context As RequestContext, ByVal controllerType As Type) As IController
                If controllerType Is Nothing Then
                    Return Nothing
                End If
                Return DirectCast(kernel.[Get](controllerType), IController)
            End Function
    
            ' Configures how abstract service types are mapped to concrete implementations'
            Private Class DIServices
                Inherits NinjectModule
    
                Public Overrides Sub Load()
    
                    Bind(Of Abstract.IProductRepository)().[To](Of Concrete.XMLProductRepository)()
                    Bind(Of Abstract.ICategoryRepository)().[To](Of Concrete.XMLCategoryRepository)()
    
                End Sub
    
            End Class
    
        End Class
    
    End Namespace
  4. As can be seen in the example above I am specifying to use the concrete XML repository class where ever the repository interfaces are referenced. An example of this use is:
    Namespace Controllers
        Public Class ProductController
            Inherits System.Web.Mvc.Controller
    
            Private m_ProductsRespository As IProductRepository
    
            Public Sub New(ByVal prodRepos As IProductRepository)
                m_ProductsRespository = prodRepos
            End Sub
    
            Function Index() As ActionResult
                Return View()
            End Function
    
        End Class
    End Namespace
  5. Now we need to add in the code that will get the dependency to fire, this is done with a line in the Application_Start function (in Global.asax):
    Sub Application_Start()
    
            AreaRegistration.RegisterAllAreas()
            RegisterGlobalFilters(GlobalFilters.Filters)
            RegisterRoutes(RouteTable.Routes)
    
            ' The Important line below'
            ControllerBuilder.Current.SetControllerFactory(New DIControllerFactory())
    
        End Sub
  6. This now means when the application starts in IIS Ninject will sort out the dependencies based on the code in DIServices, such as in the case of the ProductController example above. The main example used here is so that the products & categories for an eCommerce site can come from any type of repository - XML, SQL Server, MYSQL, Magic, etc.

Hopefully this will help you setup dependency injection in your MVC app. If you wish to see how I’ve got the repositories setup then here are my base class/intertface setups:

Namespace Repositories.Abstract

    Public Interface IProductRepository

        Function getProduct(ByVal id As String) As Models.Product

    End Interface

End Namespace
Imports System.IO

Namespace Repositories.Concrete.XML

    Public Class XMLProductRepository
        Implements Abstract.IProductRepository

        Public Function getProduct(ByVal id As String) As Models.Product Implements Abstract.IProductRepository.getProduct

            Dim prodMdl As Models.Product = Nothing

            Dim x As New System.Xml.Serialization.XmlSerializer(GetType(Models.Product))
            Dim fileName As String = Path.Combine(System.Web.HttpContext.Current.Server.MapPath("~/App_Data/Products/"), id & ".xml")

            If File.Exists(fileName) Then
                Using oStmR As New StreamReader(fileName)
                    prodMdl = CType(x.Deserialize(oStmR), Models.Product)
                    oStmR.Close()
                End Using
            End If

            Return prodMdl

        End Function

    End Class

End Namespace