.NET Core API with DynamoDB Context Dependency Injection

The Problem

Today I faced a problem which I knew would come, but didn’t have time to implement properly at the time. There were more urgent tasks at hand before, deadlines to be met and this issue remained unresolved.

Amazon’s .NET Core SDK is very good for the most part. Everything works as it should. But let’s say you use the .NET Object Persistence Model for DynamoDB in your service and you have mapped C# classes to DynamoDB tables like this:

[DynamoDBTable("Player")]
public class Player
{
    [DynamoDBHashKey]
    public int Id { get; set; }

    [DynamoDBProperty]
    public string Name { get; set; }

    [DynamoDBProperty]
    public int HitPoints { get; set; }

    [DynamoDBProperty]
    public int Gold { get; set; }

    [DynamoDBProperty]
    public int Level { get; set; }

    [DynamoDBProperty]
    public List<string> Items { get; set; }
}

[DynamoDBTable("Location")]
public class Location
{
    [DynamoDBHashKey]
    public int Id { get; set; }

    [DynamoDBProperty]
    public string Name { get; set; }

    [DynamoDBProperty]
    public string Description { get; set; }
}

It works well. Everything is mapped as it should. You have a service that calls the .NET SDK so you can write, read and delete data from the context like this:

public async Task<T> GetAsync<T>(string id)
{
    try
    {
        DynamoDBContext context = new DynamoDBContext(_dynamoDbClient);
        return await context.LoadAsync<T>(id);
    }
    catch (Exception ex)
    {
        throw new Exception($"Amazon error in Get operation! Error: {ex}");
    }
}

public async Task WriteAsync<T>(T item)
{
    try
    {
        DynamoDBContext context = new DynamoDBContext(_dynamoDbClient);
        await context.SaveAsync(item);
    }
    catch (Exception ex)
    {
        throw new Exception($"Amazon error in Write operation! Error: {ex}");
    }
}

public async Task DeleteAsync<T>(T item)
{
    try
    {
        DynamoDBContext context = new DynamoDBContext(_dynamoDbClient);
        await context.DeleteAsync(item)
    }
    catch (Exception ex)
    {
        throw new Exception($"Amazon error in Delete operation! Error: {ex}");
    }
}

It’s all fun and games until you have to use different databases for different environments! Of course you can use the Document Model, but then you lose the nice mapping to C# classes and have to work with generic documents.

C# attributes are not meant to change at runtime, so we’re in a tough spot! What can we do to tell our build pipeline which tables we’d like to use?

The Solution

.NET Core’s powerful dependency injection comes to our rescue! First we create the same section (with the respective databases for our environments) in our appsettings.Development.json

"DynamoDbTables": {
    "Player": "DevPlayer",
    "Location": "DevLocation"
}

and appsettings.json file

"DynamoDbTables": {
    "Player": "ProdPlayer",
    "Location": "ProdLocation"
}

Then we head up and create a simple configuration class which we’ll bind to our DynamoDbTables JSON object later

public class DynamoDbOptions
{
    public string Player { get; set; }
    public string Location { get; set; }
}

After that we have to create our custom DynamoDb context interface

public interface IDynamoDbContext<T> : IDisposable where T : class
{
    Task<T> GetByIdAsync(string id);
    Task SaveAsync(T item);
    Task DeleteByIdAsync(T item);
}

and the context class which we’ll inject in our services

public class DynamoDbContext<T> : DynamoDBContext, IDynamoDbContext<T>
    where T : class
{
    private DynamoDBOperationConfig _config;

    public DynamoDbContext(IAmazonDynamoDB client, string tableName)
        : base(client)
    {
        _config = new DynamoDBOperationConfig()
        {
            OverrideTableName = tableName
        };
    }

    public async Task<T> GetByIdAsync(string id)
    {
        return await base.LoadAsync<T>(id, _config);
    }

    public async Task SaveAsync(T item)
    {
        await base.SaveAsync(item, _config);
    }

    public async Task DeleteByIdAsync(T item)
    {
        await base.DeleteAsync(item, _config);
    }
}

Then we go to our Startup.cs and add a couple of lines to our ConfigureServices() method

// AWS Options
var awsOptions = Configuration.GetAWSOptions();
services.AddDefaultAWSOptions(awsOptions);

var client = awsOptions.CreateServiceClient<IAmazonDynamoDB>();
var dynamoDbOptions = new DynamoDbOptions();
ConfigurationBinder.Bind(Configuration.GetSection("DynamoDbTables"), dynamoDbOptions);

// This is where the magic happens
services.AddScoped<IDynamoDbContext<Player>>(provider => new DynamoDbContext<Player>(client, dynamoDbOptions.Player));
services.AddScoped<IDynamoDbContext<Location>>(provider => new DynamoDbContext<Location>(client, dynamoDbOptions.Location));

And voilĂ ! We can now remove the [DynamoDBTable("SomeTableName")] attributes from our classes. We can keep both [DynamoDBHashKey] and [DynamoDBProperty] attributes. If we need custom converters we can just add [DynamoDBProperty(typeof(SomeConverter))] attribute to our property.

// No table name attribute needed!
public class Player
{
    [DynamoDBHashKey]
    public int Id { get; set; }

    [DynamoDBProperty]
    public string Name { get; set; }

    [DynamoDBProperty]
    public int HitPoints { get; set; }

    [DynamoDBProperty]
    public int Gold { get; set; }

    [DynamoDBProperty]
    public int Level { get; set; }

    [DynamoDBProperty]
    public List<string> Items { get; set; }
}

// No table name attribute needed!
public class Location
{
    [DynamoDBHashKey]
    public int Id { get; set; }

    [DynamoDBProperty]
    public string Name { get; set; }

    [DynamoDBProperty]
    public string Description { get; set; }
}

and we can use the desired context with their respective tables in our services!

private IDynamoDbContext<Player> _playerContext;
private IDynamoDbContext<Location> _locationContext;

public AwesomeDynamoDbService(IDynamoDbContext<Player> playerContext, IDynamoDbContext<Location> locationContext)
{
    _playerContext = playerContext;
    _locationContext = locationContext;
}

public async Task<User> GetUserAsync(string id)
{
    try
    {
        return await _playerContext.GetByIdAsync(id);
    }
    catch (Exception ex)
    {
        throw new Exception($"Amazon error in GetUser table operation! Error: {ex}");
    }
}

public async Task<Location> GetLocationAsync(string id)
{
    try
    {
        return await _locationContext.GetByIdAsync(id);
    }
    catch (Exception ex)
    {
        throw new Exception($"Amazon error in GetLocation table operation! Error: {ex}");
    }
}

Depending on your case you can use different services for different tables or other requirements. This solution might seem a bit more cumbersome than the original, but it makes us flexible in case we need to work with multiple databases and different build environments. The only thing you have to do is to tell your build tool/pipeline to change the table names in your appsettings.json files.

This method also is more robust when it comes to testing because it’ll be much easier to mock! All in all I’m really happy with how it all turned out!

 
comments powered by Disqus