The standard repositories#
In the previous tutorial, I covered how to get started with the Datasync Community Toolkit. In this tutorial, I am going to delve into the standard repositories that can be leveraged to implement database-driven datasync endpoints in your application.
The Entity Framework Core repository#
Introduced in the last tutorial, the Entity Framework Core repository is the most popular repository because it connects to so many different databases. There are explicit notes for:
Any database that has an Entity Framework Core driver should work, although you may need to determine how to set up the database and which style of table data you need. As mentioned in the previous tutorial, there are four different implementations of ITableData
to choose from:
EntityTableData
is used whenever the server can controlUpdatedAt
andVersion
.CosmosEntityTableData
is used specifically for Cosmos DB. Cosmos controlsVersion
but notUpdatedAt
.SqliteEntityTableData
is used specifically for Sqlite. Sqlite does not storeUpdatedAt
with the right resolution, so special care is needed.RepositoryControlledEntityTableData
is used when the server cannot controlUpdatedAt
andVersion
- the repository controls them instead.
Make sure you reference the provided notes before implementing your database tables.
Warning
The Datasync Community Toolkit relies on UpdatedAt
being stored with millisecond or better accuracy. The DateTime
data type used by Sqlite stores data with second accuracy. As such, you need to exercise great care when implementing a Sqlite table.
The In Memory Repository#
When I started writing the datasync service, I needed a simple repository based in-memory to use when testing and I didn’t want to be guessing if the problem was a problem with my code or a peculiarity of the Entity Framework Core libraries. I wrote a simple in-memory repository to solve this. It turns out that in-memory repositories are really useful when the data doesn’t change very often, even if the data is stored in a database. It’s also useful for test applications before you add a database for persistent storage.
Let’s start with a common situation - you have a set of categories that are used in your application. The categories don’t change much and the user can’t change them. This is an ideal situation for an in-memory repository. The in-memory repository requires the CommunityToolkit.Datasync.Server.InMemory
NuGet package.
First, let’s define a new class for the model:
public class CategoryDTO : InMemoryTableData
{
[Required, StringLength(64, MinimumLength = 1)]
public string CategoryName { get; set; } = string.Empty;
}
Create an initializer:
public interface IDatasyncInitializer
{
Task InitializeAsync(CancellationToken cancellationToken = default);
}
public class DatasyncInitializer(AppDbContext context, IRepository<CategoryDTO> repository) : IDatasyncInitializer
{
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
IList<Category> categories = await context.Categories.ToListAsync();
IList<CategoryDTO> seed = categories.Select(x => Convert(x)).ToList();
IQueryable<CategoryDTO> queryable = await repository.AsQueryableAsync(cancellationToken);
foreach (CategoryDTO dto in seed)
{
if (!queryable.Any(x => x.CategoryName.Equals(dto.CategoryName, StringComparison.OrdinalIgnoreCase)))
{
await repository.CreateAsync(dto, cancellationToken);
}
}
}
private static CategoryDTO Convert(Category category) => new()
{
Id = category.MobileId,
UpdatedAt = category.UpdatedAt ?? DateTimeOffset.UnixEpoch,
Version = category.Version ?? Guid.NewGuid().ToByteArray(),
Deleted = false,
CategoryName = category.CategoryName
};
}
I’ve obviously made this a little more complex than it needs to be. I’ve ensured that the database model is different from the data transfer object (or DTO), so I need to translate between them.
- The database model uses an auto-incrementing ID, but my DTO requires a globally unique ID. Create a
MobileId
field to solve this. - The
UpdatedAt
is stored in the database but it might be null. Always ensure thatUpdatedAt
hs a valid value that is more recent than the UNIX epoch. - Similarly, the
Version
may not be set. The service (and clients) expect each entity to have a version. - The database model doesn’t have a
Deleted
flag. It isn’t used in this case, but the protocol requires it exist.
Info
The term “DTO” refers to a “Data Transfer Object” - an object that is transferred between client and server. It is so named to distinguish it from the database object (which normally carries the real entity name). You will often see Model
and ModelDTO
as pairs.
In the InitializeAsync()
method, the database is queried to get the list of categories, then converted to the version that is sent to clients.
Next, add the repository to the services in Program.cs
:
builder.Services.AddSingleton<IRepository<CategoryDTO>, InMemoryRepository<CategoryDTO>>();
builder.Services.AddScoped<IDatasyncInitializer, DatasyncInitializer>();
Tip
The DbContext
used in the Entity Framework Core is created for each request, thus the repository is also created per-request. When using an in-memory repository, you want to use the same repository for all requests, so use a singleton. The in-memory repository is thread-safe.
Initialize the repository immediately after you build the web application. Since I am using a DbInitializer
as well, I can include the datasync initializer in the same area. This is what a typical initialization (immediately after the application is built) looks like:
TimeSpan allowedInitializationTime = TimeSpan.FromMinutes(5);
CancellationTokenSource cts = new();
using (AsyncServiceScope scope = app.Services.CreateAsyncScope())
{
IDbInitializer dbInitializer = scope.ServiceProvider.GetRequiredService<IDbInitializer>();
IDatasyncInitializer datasyncInitializer = scope.ServiceProvider.GetRequiredService<IDatasyncInitializer>();
cts.CancelAfter(allowedInitializationTime);
try
{
CancellationToken ct = cts.Token;
await dbInitializer.InitializeAsync(ct);
await datasyncInitializer.InitializeAsync(ct);
}
catch (OperationCanceledException)
{
throw new ApplicationException($"Initialization failed to complete within {allowedInitializationTime}");
}
}
Create a datasync table controller that uses the repository:
[Route("tables/[controller]")]
public class CategoryController : TableController<CategoryDTO>
{
public CategoryController(IRepository<CategoryDTO> repository, ILogger<CategoryController> logger)
: base(repository)
{
Logger = logger;
}
}
As I showed in the last tutorial, you can use Swashbuckle to interact with the server. Note that the /tables/category
endpoint acts just like the database backed endpoint. However, data does not get persisted to the database and all data is served from memory. You can use all the same options as the Entity Framework Core repository. These were introduced in the last tutorial.
Info
You are probably wondering how to make this table “read-only” at this point. The answer to your question is “Access Control Providers” and I will be covering that topic in depth in a later tutorial.
The Automapper Repository#
The next repository type is the Automapper repository. Unlike the Entity Framework Core repository and the in-memory repository, this repository wraps another repository; it transforms the data before it is stored and while it is being read from the database.
We’ve introduced Data Transfer Objects (or DTOs) above. Separating the database model from the model sent to the client is a commonly required strategy. Not all properties are relevant to clients, or you need to rename some properties on the way through, or do some data transformation. AutoMapper is very flexible in this regard.
Let’s say you are using a database table, but you’ve recently updated the database table to add the required metadata. The UpdatedAt
property may not be set (as required by the protocol).
First, set up your Automapper.
- Install the
Automapper
NuGet package. -
Create a profile. Here is mine:
using AutoMapper; namespace InMemoryDatasyncService.Models; public class TodoItemProfile : Profile { public TodoItemProfile() { CreateMap<TodoItem, TodoItemDTO>() .ForMember(dest => dest.UpdatedAt, opt => opt.NullSubstitute(DateTimeOffset.UnixEpoch)) .ReverseMap(); } }
I am using the automapper to automatically set the
UpdatedAt
property if it is not set. -
Install the profile into the services collection within your
Program.cs
:builder.Services.AddAutoMapper(typeof(TodoItemProfile));
Each entity being auto-mapped needs both a forward and a reverse map. You should take care to ensure that the conditions required for the datasync metadata are met for each model:
UpdatedAt
must be unique within the table and have millisecondsec precision.Version
should be a byte array that changes on every write.Id
must be a globally unique string.
You can use any of the AutoMapper features, including custom type converters, custom resolvers, and null substitution to ensure that your conversion works properly.
Both the model and the DTO must be “datasync ready” - i.e. they must inherit from something that implements ITableData
. The model must be suitable for use with the underlying repository. Obviously, I am not doing anything particularly challenging in my example.
Tip
You should always write unit tests for your automapper profile to ensure that the model can be converted to and from a DTO, including when the data is not set.
As always, create a suitable table controller. The MappedTableRepository
is in the CommunityToolkit.Datasync.Server.Automapper
NuGet package. Here is a typical controller:
[Route("api/todoitem")]
public class AutomappedController : TableController<TodoItemDTO>
{
public AutomappedController(IMapper mapper, AppDbContext context) : base()
{
var efrepo = new EntityTableRepository<TodoItem>(context);
Repository = new MappedTableRepository<TodoItem, TodoItemDTO>(mapper, efrepo);
}
}
I don’t find the MappedTableRepository
that useful since it wraps an existing repository, which means the database model must conform to the datasync standards. In the next tutorial, we’ll take a look at custom repositories that don’t have this restriction.
The MongoDB Repository#
Among NoSQL data stores, MongoDB is perhaps the most well-known. NoSQL databases store JSON documents instead of table data. However, the Datasync Community Toolkit is a table-driven system, so it only works with “flat” JSON documents (without embedded arrays or objects).
Tip
In general, you should avoid NoSQL stores because they don’t have the expansive query capabilities of SQL stores. For example, you can write queries that perform math, string, and date/time operations with SQL, but those capabilities generally don’t exist with NoSQL stores. NoSQL stores are good options when all your processing is done on the client, however.
Like other repositories:
- You must add a NuGet package; in this case
CommunityToolkit.Datasync.Server.MongoDB
. - Your entities must inherit from
MongoTableData
.
When creating your table controller, you need a reference to the MongoClient
. It is relatively easy to exhaust the connections available to MongoDb servers, so you should generally use a singleton client or a connection pool. For example, here is an example of a simple wrapper of the client:
public class MongoClientFactory
{
private readonly MongoClient client;
public MongoClientFactory(string connectionString)
{
MongoClientSettings clientSettings = MongoClientSettings.FromConnectionString(connectionString);
this.client = new MongoClient(clientSettings);
}
public MongoClient Client { get => this.client; }
}
I can set this up as a singleton when setting up all my other services:
builder.Services.AddSingleton<MongoClientFactory>(new MongoClientFactory(builder.Configuration.GetConnectionString("MongoDb")));
Now I can create the normal table controller:
[Route("tables/[controller]")]
public class EntityController : TableController<Entity>
{
public EntityController(MongoClientFactory clientFactory, ILogger<EntityController> logger)
{
IMongoDatabase database = clientFactory.Client.GetDatabase("synchronized-data");
Repository = new MongoDBRepository<Entity>(database.GetCollection<Entity>("entities"));
Logger = logger;
}
}
Unlike NoSQL databases, you cannot store embedded documents. For example, it’s perfectly fine to store the following entity:
{
"name": "Joe Smith",
"address": {
"line1": "1 Main Street",
"city": "New York City",
"state": "NY"
}
}
However, this doesn’t work with the Datasync Community Toolkit - only “flat” entities are allowed. In this case, the “address” is an object which does not have a primitive data type, so it is disallowed.
The LiteDB Repository#
Finally, there is a LitDB based repository. LiteDB is a serverless embedded database that is written entirely in .NET, so it’s ideal for cases where you need “something” to be a database but you don’t want to go to the effort of setting up a server. SQLite also fits the bill here. However, SQLite has some restrictions around data/time handling that make it unsuitable for datasync applications on the server. LiteDB doesn’t have these restrictions. It naturally stores date/times with an ISO-8601 conversion and millisecond accuracy. The LiteDB can be used “in-memory” on “on-disk”.
Like other repositories:
- You must add a NuGet package; in this case
CommunityToolkit.Datasync.Server.LiteDB
. - Your entities must inherit from
LiteDbTableData
.
Similar to the MongoDB repository, add the database to the services as a singleton:
string liteDBConnectionString = builder.Configuration.GetConnectionString("LiteDB");
builder.Services.AddSingleton<LiteDatabase>(new LiteDatabase(liteDBConnectionString));
Then, build your controller:
[Route("tables/[controller]")]
public class LiteItemController(LiteDatabase db) : TableController<LiteItem>(new LiteDbRepository<LiteItem>(db, "todoitems"))
{
}
Final thoughts#
The Datasync Community Toolkit has five standard repositories:
- AutoMapper
- Entity Framework Core
- In-Memory
- LiteDb
- MongoDB
These cover the majority of situations that you will come across. They get you started fast and efficiently while still being “production ready”.
However, there are always those cases when one of the standard repositories doesn’t work for you. The canonical example for such a situation is when you are using an existing database table that uses auto-incrementing integers for the key. The pluggable architecture of the Datasync Community Toolkit also allows you to write your own repositories. In the next tutorial, I’m going to dive into how you do that and how you can use custom repositories for the specific example of handling an existing database table.