How to build a URL Shortener with C# .NET and Redis

October 04, 2022
Written by
Reviewed by

URLs are used to locate resources on the internet, but URLs can be long making them hard to use. Especially when you have to type the URL manually, even more so when having to do so on a mobile phone. Long URLs are also problematic when you have a limited amount of characters such as within an SMS segment or a Tweet.

A solution to this is to use a URL shortener which will create URLs that are short and sweet. When you open the shortened URL, the URL will forward you to the long destination URL. This makes it easier to manually type the URL and also save precious characters.

Shortened URLs also obfuscate the real URL so users don't know where they will land on the internet when they click the URL. This could be abused by malicious actors, so make sure your URL shortener is secure! If you're using a free URL shortener, users may be wary because anyone could've created the shortened URL. Using your own branded domain name will establish more trust in the shortened URLs you send.

In this tutorial, you'll learn how to create your own URL shortener using C#, .NET, and Redis.

Prerequisites

Here’s what you will need to follow along:

You can find the source code for this tutorial on GitHub. Use it if you run into any issues, or submit an issue, if you run into problems.

Before creating your application, let's take a look at how URL shorteners work and what you'll be building.

URL Shortener Solution

There are two parts to URL shorteners: forwarding the shortened URLs to the destination URL and the management of the shortened URLs. While these two parts could live within a single application, you will build separate applications for URL management and for URL forwarding.

The two applications will work together as shown in the diagram above.

An administrator can use the CLI CRUD (Create Read Update Delete) application (1) to manage the shortened URL data in a Redis Database (2).

Let's say a mobile user received an SMS with a shortened URL. When the user clicks on the shortened URL, the web browser sends an HTTP GET request to that URL which is received by the Forwarder App (1). The forwarder app will look at the path of the request and look up the destination URL in the same Redis Database and return it to the Forwarder App (2). The Forwarder App then sends back an HTTP response with status code 307 Temporary Redirect pointing towards the destination URL (3).

You'll build the Forwarder App using an ASP.NET Core Minimal API, and you'll also build a command-line interface (CLI) application to provide the CRUD functionality. The CLI application will take advantage of the open-source System.CommandLine libraries. While these libraries are still in preview, they work well and provide common functionality needed to build CLI applications. While you'll build the CRUD functionality in a CLI app, you could build the same app as a website or API using ASP.NET Core, or client app using WPF, WinForms, Avalonia, MAUI, etc.

The CLI application will store the collection of shortened URLs in a Redis database. The key of each shortened URL will be the unique path that will be used to browse to the URL, and the value will be the destination URL.

I chose Redis for this application because it works well as a key-value database, it is well-supported, and there's a great .NET library to interact with Redis. However, you could use any data store you'd like in your own implementation.

Lastly, since these two applications will interact with the same data store and perform some of the same functionality, you'll create a class library for shared functionality for the CRUD operations and validation.

Create the Data Layer

First, open a terminal and run the following commands to create a folder, navigate into it, and create a solution file using the .NET CLI:

mkdir UrlShortener
cd UrlShortener
dotnet new sln

Then, create a new class library project for the data layer, and add the library to the solution:

dotnet new classlib -o UrlShortener.Data
dotnet sln add UrlShortener.Data

Now open the solution with your preferred editor.

Rename the default C# file called Class1.cs to ShortUrl.cs, and add the following C# code to ShortUrl.cs:

namespace UrlShortener.Data;

public sealed record ShortUrl(string? Destination, string? Path);

This record will hold on to the Destination and the Path. Path is the unique key that will be matched with the path from the incoming HTTP request in the Forwarder App, and Destination will be the URL the Forwarder App will forward the user to.

Next, create the ShortUrlValidator.cs file which will have code to validate the ShortUrl and its properties. Then, add the following C# code to the file:

using System.Text.RegularExpressions;

namespace UrlShortener.Data;

public static class ShortUrlValidator
{
    private static readonly Regex PathRegex = new Regex(
        "^[a-zA-Z0-9_-]*$",
        RegexOptions.None,
        TimeSpan.FromMilliseconds(1)
    );

    public static bool Validate(this ShortUrl shortUrl, out IDictionary<string, string[]> validationResults)
    {
        validationResults = new Dictionary<string, string[]>();
        var isDestinationValid = ValidateDestination(
            shortUrl.Destination,
            out var destinationValidationResults
        );
        var isPathValid = ValidatePath(
            shortUrl.Path,
            out var pathValidationResults
        );

        validationResults.Add("destination", destinationValidationResults);
        validationResults.Add("path", pathValidationResults);

        return isDestinationValid && isPathValid;
    }

    public static bool ValidateDestination(string? destination, out string[] validationResults)
    {
        if (destination == null)
        {
            validationResults = new[] {"Destination cannot be null."};
            return false;
        }

        if (destination == "")
        {
            validationResults = new[] {"Destination cannot empty."};
            return false;
        }

        if (!Uri.IsWellFormedUriString(destination, UriKind.Absolute))
        {
            validationResults = new[] {"Destination has to be a valid absolute URL."};
            return false;
        }

        validationResults = Array.Empty<string>();
        return true;
    }

    public static bool ValidatePath(string? path, out string[] validationResults)
    {
        if (path == null)
        {
            validationResults = new[] {"Path cannot be null."};
            return false;
        }

        if (path == "")
        {
            validationResults = new[] {"Path cannot empty."};
            return false;
        }

        var validationResultsList = new List<string>();
        if (path.Length > 10)
            validationResultsList.Add("Path cannot be longer than 10 characters.");

        if (!PathRegex.IsMatch(path))
            validationResultsList.Add("Path can only contain alphanumeric characters, underscores, and dashes.");
        
        validationResults = validationResultsList.ToArray();
        return validationResultsList.Count > 0;
    }
}

ShortUrlValidator has methods for validating the ShortUrl record and for its individual properties. For each validation rule that is not met, a string is added to the validationResults list. The validationResults are then added together into a dictionary. These are:

  • The ShortUrl.Path property can not be null or empty, not be longer than 10 characters, and has to match the PathRegex which only allows alphanumeric characters, underscores, and dashes.
  • The ShortUrl.Destination property can not be null or empty, and has to be a well formed absolute URL, meaning a URL of format <scheme>://<hostname>:<port> and optionally a path, query, and fragment.

These validation rules will be used in both the Forwarder App and the CLI CRUD App.

If you don't want to manually write your validation code, there are some great libraries out there that can help such as FluentValidation for any type of project, and MiniValidation to get the same validation experience as in ASP.NET Core MVC.

Now, the most important responsibility of this project is to manage the data in the Redis database. There's a great library for .NET by StackExchange, the StackOverflow company, for interacting with Redis databases called StackExchange.Redis. Add the StackExchange.Redis NuGet package to the data project using the .NET CLI:

dotnet add UrlShortener.Data package StackExchange.Redis

Now, create a new file ShortUrlRepository.cs and add the following C# code:

namespace UrlShortener.Data;

using StackExchange.Redis;

public sealed class ShortUrlRepository
{
    private readonly ConnectionMultiplexer redisConnection;
    private readonly IDatabase redisDatabase;

    public ShortUrlRepository(ConnectionMultiplexer redisConnection)
    {
        this.redisConnection = redisConnection;
        this.redisDatabase = redisConnection.GetDatabase();
    }

    public async Task Create(ShortUrl shortUrl)
    {
        if (await Exists(shortUrl.Path))
            throw new Exception($"Shortened URL with path '{shortUrl.Path}' already exists.");

        var urlWasSet = await redisDatabase.StringSetAsync(shortUrl.Path, shortUrl.Destination);
        if (!urlWasSet)
            throw new Exception($"Failed to create shortened URL.");
    }

    public async Task Update(ShortUrl shortUrl)
    {
        if (await Exists(shortUrl.Path) == false)
            throw new Exception($"Shortened URL with path '{shortUrl.Path}' does not exist.");

        var urlWasSet = await redisDatabase.StringSetAsync(shortUrl.Path, shortUrl.Destination);
        if (!urlWasSet)
            throw new Exception($"Failed to update shortened URL.");
    }

    public async Task Delete(string path)
    {
        if (await Exists(path) == false)
            throw new Exception($"Shortened URL with path '{path}' does not exist.");

        var urlWasDeleted = await redisDatabase.KeyDeleteAsync(path);
        if (!urlWasDeleted)
            throw new Exception("Failed to delete shortened URL.");
    }

    public async Task<ShortUrl?> Get(string path)
    {
        if (await Exists(path) == false)
            throw new Exception($"Shortened URL with path '{path}' does not exist.");

        var redisValue = await redisDatabase.StringGetAsync(path);
        if (redisValue.IsNullOrEmpty)
            return null;
        
        return new ShortUrl(redisValue.ToString(), path);
    }

    public async Task<List<ShortUrl>> GetAll()
    {
        var redisServers = redisConnection.GetServers();
        var keys = new List<string>();
        foreach (var redisServer in redisServers)
        {
            await foreach (var redisKey in redisServer.KeysAsync())
            {
                var key = redisKey.ToString();
                if (keys.Contains(key)) continue;
                keys.Add(key);
            }
        }

        var redisDb = redisConnection.GetDatabase();

        var shortUrls = new List<ShortUrl>();
        foreach (var key in keys)
        {
            var redisValue = redisDb.StringGet(key);
            shortUrls.Add(new ShortUrl(redisValue.ToString(), key));
        }

        return shortUrls;
    }

    public async Task<bool> Exists(string? path)
        => await redisDatabase.KeyExistsAsync(path);
}

The ShortUrlRepository is responsible for interacting with the Redis database to manage the shortened URLs. ShortUrlRepository accepts an instance of ConnectionMultiplexer via its constructor which is used to get an IDatabase instance via redisConnection.GetDatabase(). ConnectionMultiplexer takes care of connecting to the Redis database, while the IDatabase class provides the Redis commands as .NET methods.

The ShortUrlRepository has the following methods:

  • Create to create a ShortUrl in the Redis database with the key being the path of the shortened URL, and the value being the destination URL.
  • Update to update an existing ShortUrl in the Redis database.
  • Delete to delete a ShortUrl by deleting the key in the Redis database.
  • Get to get the ShortUrl via the path.
  • GetAll to retrieve all the shortened URLs by retrieving all keys from all the connected Redis servers and then retrieve the values from the Redis database.

This provides all the CRUD functionality for shortened URLs.

Now let's use these classes to create the CRUD CLI App.

Build the Command-Line CRUD Application

Create a new console project in your solution folder, and add the project to the solution:

dotnet new console -o UrlShortener.Cli
dotnet sln add UrlShortener.Cli

Then, add a reference from the CLI project to the data project:

dotnet add UrlShortener.Cli reference UrlShortener.Data

Now you can use the public classes from the data project in your CLI project.

Next, add the StackExchange.Redis NuGet package to this project as well:

dotnet add UrlShortener.Cli package StackExchange.Redis
dotnet add UrlShortener.Cli package System.CommandLine --version 2.0.0-beta4.22272.1

At the time of writing, there are only prerelease versions of this library available. Once this library is fully released, you'll be able to add it without having to explicitly specify the version or the --prerelease argument.

The System.CommandLine libraries provide common functionality for building CLI applications. You can define your arguments and commands in C# and the library will execute your commands, and generate help text, command completion, and more.

Open the UrlShortener.Cli/Program.cs file, and replace the code with the following C#:

using System.CommandLine;
using StackExchange.Redis;
using UrlShortener.Data;

var destinationOption = new Option<string>(
    new[] {"--destination-url", "-d"},
    description: "The URL that the shortened URL will forward to."
);
destinationOption.IsRequired = true;
destinationOption.AddValidator(result =>
{
    var destination = result.Tokens[0].Value;
    if (ShortUrlValidator.ValidateDestination(destination, out var validationResults) == false)
    {
        result.ErrorMessage = string.Join(", ", validationResults);
    }
});

var pathOption = new Option<string>(
    new[] {"--path", "-p"},
    description: "The path used for the shortened URL."
);
pathOption.IsRequired = true;
pathOption.AddValidator(result =>
{
    var path = result.Tokens[0].Value;
    if (ShortUrlValidator.ValidatePath(path, out var validationResults) == false)
    {
        result.ErrorMessage = string.Join(", ", validationResults);
    }
});

var connectionStringOption = new Option<string?>(
    new[] {"--connection-string", "-c"},
    description: "Connection string to connect to the Redis Database where URLs are stored. " +
                 "Alternatively, you can set the 'URL_SHORTENER_CONNECTION_STRING'."
);
var envConnectionString = Environment.GetEnvironmentVariable("URL_SHORTENER_CONNECTION_STRING");
if (string.IsNullOrEmpty(envConnectionString))
{
    connectionStringOption.IsRequired = true;
}

This code defines the options that you can pass into the CLI application:

  • destinationOption can be passed in using -d or --destination-url and is required.
  • pathOption can be passed in using -p or --path and is also required.
  • connectionStringOption can be passed in using -c or --connection-string. Alternatively, you can set the URL_SHORTENER_CONNECTION_STRING environment variable which is preferred over passing sensitive strings as an argument. If the URL_SHORTENER_CONNECTION_STRING environment is not present, then the connectionStringOption is required.

The destinationOption and pathOption validate the argument by passing in the argument value to their respective ShortUrlValidator methods. The validation results are then concatenated and set to result.ErrorMessage which will be displayed as errors to the user.

Now that the options are defined, you can create commands that receive the options.
Add the following code after your existing code:

var rootCommand = new RootCommand("Manage the shortened URLs.");

async Task<ConnectionMultiplexer> GetRedisConnection(string? connectionString)
{
    var redisConnection = await ConnectionMultiplexer.ConnectAsync(
        connectionString ??
        envConnectionString ??
        throw new Exception("Missing connection string.")
    );
    return redisConnection;
}

var createCommand = new Command("create", "Create a shortened URL")
{
    destinationOption,
    pathOption,
    connectionStringOption
};

createCommand.SetHandler(async (destination, path, connectionString) =>
{
    var shortUrlRepository = new ShortUrlRepository(await GetRedisConnection(connectionString));
    try
    {
        await shortUrlRepository.Create(new ShortUrl(destination, path));
        Console.WriteLine("Shortened URL created.");
    }
    catch (Exception e)
    {
        Console.Error.WriteLine(e.Message);
    }
}, destinationOption, pathOption, connectionStringOption);

rootCommand.AddCommand(createCommand);

The rootCommand is the command that is invoked when you invoke the CLI application without passing any subcommands. The create command takes in the destinationOption, pathOption, and connectionStringOption. The lambda passed into the SetHandler method receives the values for the options and will be executed when the command is run from the CLI.

Since the connectionStringOption may not be required, the connectionString parameter may be null. The GetRedisConnection method checks if it is null, and if so, returns the value from the environment variables. If that is also null, it'll throw an exception.

The command handler will create a new ShortUrlRepository passing in the connection string, and use the Create method to create a new shortened URL.

Now, add the following code which will add the update, delete, get, and list commands:

var updateCommand = new Command("update", "Update a shortened URL")
{
    destinationOption,
    pathOption,
    connectionStringOption
};

updateCommand.SetHandler(async (destination, path, connectionString) =>
{
    var shortUrlRepository = new ShortUrlRepository(await GetRedisConnection(connectionString));
    try
    {
        await shortUrlRepository.Update(new ShortUrl(destination, path));
        Console.WriteLine("Shortened URL updated.");
    }
    catch (Exception e)
    {
        Console.Error.WriteLine(e.Message);
    }
}, destinationOption, pathOption, connectionStringOption);

rootCommand.AddCommand(updateCommand);

var deleteCommand = new Command("delete", "Delete a shortened URL")
{
    pathOption,
    connectionStringOption
};

deleteCommand.SetHandler(async (path, connectionString) =>
{
    var shortUrlRepository = new ShortUrlRepository(await GetRedisConnection(connectionString));
    try
    {
        await shortUrlRepository.Delete(path);
        Console.WriteLine("Shortened URL deleted.");
    }
    catch (Exception e)
    {
        Console.Error.WriteLine(e.Message);
    }
}, pathOption, connectionStringOption);

rootCommand.AddCommand(deleteCommand);

var getCommand = new Command("get", "Get a shortened URL")
{
    pathOption,
    connectionStringOption
};

getCommand.SetHandler(async (path, connectionString) =>
{
    var shortUrlRepository = new ShortUrlRepository(await GetRedisConnection(connectionString));
    try
    {
        var shortUrl = await shortUrlRepository.Get(path);
        if (shortUrl == null)
            Console.Error.WriteLine($"Shortened URL for path '{path}' not found.");
        else
            Console.WriteLine($"Destination URL: {shortUrl.Destination}, Path: {path}");
    }
    catch (Exception e)
    {
        Console.Error.WriteLine(e.Message);
    }
}, pathOption, connectionStringOption);

rootCommand.AddCommand(getCommand);

var listCommand = new Command("list", "List shortened URLs")
{
    connectionStringOption
};

listCommand.SetHandler(async (connectionString) =>
{
    var shortUrlRepository = new ShortUrlRepository(await GetRedisConnection(connectionString));
    try
    {
        var shortUrls = await shortUrlRepository.GetAll();
        foreach (var shortUrl in shortUrls)
        {
            Console.WriteLine($"Destination URL: {shortUrl.Destination}, Path: {shortUrl.Path}");
        }
    }
    catch (Exception e)
    {
        Console.Error.WriteLine(e.Message);
    }
}, connectionStringOption);

rootCommand.AddCommand(listCommand);

Note how some commands take less options than others.

Lastly, add the following line of code:

return rootCommand.InvokeAsync(args).Result;

This line is responsible for running the commands and passing in the args string array.

Go back to your terminal and configure the URL_SHORTENER_CONNECTION_STRING environment variable:

If you use PowerShell:

$Env:URL_SHORTENER_CONNECTION_STRING = '[YOUR_CONNECTION_STRING]'

If you use CMD:

set URL_SHORTENER_CONNECTION_STRING=[YOUR_CONNECTION_STRING]

If you use Unix based shells such as Bash or Zsh:

export URL_SHORTENER_CONNECTION_STRING=[YOUR_CONNECTION_STRING]

Replace [YOUR_CONNECTION_STRING] with the connection string pointing to your Redis server.

Now you can run the project and will see helpful information about the available commands:

dotnet run --project UrlShortener.Cli

The output looks like this:

Required command was not provided.

Description:
  Manage the shortened URLs.

Usage:
  UrlShortener.Cli [command] [options]

Options:
  --version       Show version information
  -?, -h, --help  Show help and usage information

Commands:
  create  Create a shortened URL
  update  Update a shortened URL
  delete  Delete a shortened URL
  get     Get a shortened URL
  list    List shortened URLs

To get help information about specific commands, specify the command and add the --help argument:

dotnet run --project UrlShortener.Cli -- create --help

The output looks like this:

Description:
  Create a shortened URL

Usage:
  UrlShortener.Cli create [options]

Options:
  -d, --destination-url <destination-url>      The URL that the shortened URL will forward to.
  (REQUIRED)
  -p, --path <path> (REQUIRED)                 The path used for the shortened URL.
  -c, --connection-string <connection-string>  Connection string to connect to the Redis Database
                                               where URLs are stored. Alternatively, you can set
                                               the 'URL_SHORTENER_CONNECTION_STRING'.
  -?, -h, --help                               Show help and usage information

Next, try the following commands:

dotnet run --project UrlShortener.Cli -- create -d https://www.youtube.com/watch?v=dQw4w9WgXcQ -p rr
dotnet run --project UrlShortener.Cli -- create -d https://www.twilio.com -p tw
dotnet run --project UrlShortener.Cli -- create -d https://sendgrid.com -p sg
dotnet run --project UrlShortener.Cli -- create -d https://swimburger.net -p sb
dotnet run --project UrlShortener.Cli -- get -p sb
dotnet run --project UrlShortener.Cli -- update -d https://swimburger.net/blog -p sb
dotnet run --project UrlShortener.Cli -- list

Alternatively, you could publish the project and interact directly with the executable:

dotnet publish UrlShortener.Cli -o publish
cd publish
./UrlShortener.Cli list 

Great job! You built the CRUD as a CLI application, now let's build the Forwarder Application.

Build the ASP.NET Core Forwarder Application

In your terminal, head back to the solution folder and then create a new ASP.NET Core Minimal API project:

cd ..
dotnet new web -o UrlShortener.Forwarder

Just like with the CRUD CLI project, add a reference in the Forwarder project to the data project, and add the StackExchange.Redis NuGet package:

dotnet add UrlShortener.Forwarder reference UrlShortener.Data
dotnet add UrlShortener.Forwarder package StackExchange.Redis

Now, open UrlShortener.Forwarder/Program.cs and replace the contents with the following code:

using StackExchange.Redis;
using UrlShortener.Data;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("ShortenedUrlsDb")
            ?? throw new Exception("Missing 'ShortenedUrlsDb' connection string");
var redisConnection = await ConnectionMultiplexer.ConnectAsync(connectionString);
builder.Services.AddSingleton(redisConnection);
builder.Services.AddTransient<ShortUrlRepository>();

var app = builder.Build();

app.MapGet("/{path}", async (
    string path,
    ShortUrlRepository shortUrlRepository
) =>
{
    if(ShortUrlValidator.ValidatePath(path, out _))
        return Results.BadRequest();

    var shortUrl = await shortUrlRepository.Get(path);
    if (shortUrl == null || string.IsNullOrEmpty(shortUrl.Destination))
        return Results.NotFound();

    return Results.Redirect(shortUrl.Destination);
});

app.Run();

Let's look at lines 6 to 10 first. The program will retrieve the UrlsDb Redis connection string from the configuration, and if null, throw an exception. Then, a connection to the Redis server is made which is added to the Dependency Injection (DI) container as a singleton.

Next, the ShortUrlRepository is added as a transient service to the DI container. Since ShortUrlRepository accepts a ConnectionMultiplexer object via its constructor, the DI container is able to construct the ShortUrlRepository for you passing in the singleton ConnectionMultiplexer.

Next, let's look at lines 14 to 27. A new HTTP GET endpoint is added with the route /{path}. This endpoint will be invoked by any HTTP request with a single level path. Without a path, ASP.NET Core will return an HTTP status code of 404 Not Found, and so will requests with subdirectory paths.

When the endpoint is invoked, the path of the URL is passed into the path parameter of the lambda. The second lambda parameter, an instance of ShortUrlRepository, is injected by the DI container.
The path is then validated using ShortUrlValidator.ValidatePath. If the path is not valid, an HTTP status code of 400 Bad Request is returned.

If it is valid, the shortened URL is retrieved using the ShortUrlRepository.Get method. If no shortened URL is found, shortUrl will be null. If the shortUrl is null, or when it is not null but the ShortUrl.Destination is null or empty, then an HTTP status code 404 Not Found is returned. Otherwise, the endpoint responds with HTTP status code 307 Temporary Redirect, redirecting to the ShortUrl.Destination.

Now that your code is ready, you'll need to configure the ShortenedUrlsDb connection string. Run the following commands to enable user-secrets, and then configure your connection string as a user-secret:

dotnet user-secrets --project UrlShortener.Forwarder init
dotnet user-secrets --project UrlShortener.Forwarder set ConnectionStrings:ShortenedUrlsDb [YOUR_CONNECTION_STRING]

Replace [YOUR_CONNECTION_STRING] with the connection string pointing to your Redis server.

Finally, run the Forwarder application:

dotnet run --project UrlShortener.Forwarder

The output of this command will show you the URLs of your application. Open one of the URLs and try some of the shortened URLs you created earlier, like /rr, /sb, /tw, and /sg.

Next steps

You've developed a CLI application to manage shortened URLs and a web application that forwards the shortened URLs to the destination URLs. However, to make the URL as short as possible, you need to also buy a domain that is short and host your URL shortener at that domain. For example, Twilio uses twil.io as a short domain, which is much shorter than www.twilio.com. This is also a good example of how the name of your brand can still be represented in your short domain name.

There are some other ways you could improve the solution:

  • You could make the path optional when creating and updating a shortened URL, and instead randomly generate the path.
  • You could store a time to live (TTL) for every shortened URL and remove the shortened URL when the TTL expires.
  • You could track how many times a shortened URL is used for analytics purposes. You could store this data in the Redis database, or track it as an event in Segment.
  • You could add an API that is consumable from other applications. For example, an SMS application could dynamically generate shortened URLs via the API for the long URLs they are trying to send to users.
  • You could create a Graphical User Interface (GUI) to manage the shortened URL data instead of a CLI application.

If you want to use shortened URLs with Twilio SMS, Twilio Messaging has a built-in feature called Link Shortening & Click Tracking.

Want to keep learning? Learn how to respond to SMS and Voice calls using ASP.NET Core Minimal APIs.

Niels Swimberghe is a Belgian American software engineer and technical content creator at Twilio. Get in touch with Niels on Twitter @RealSwimburger and follow Niels’ personal blog on .NET, Azure, and web development at swimburger.net.