A database-agnostic, write-once, run everywhere repository pattern implementation for an entire dotnet .solution.
This isn't intended to replace any ORM or database libraries. This is a symmetrical repository pattern that allows you to handle data access in every dotnet application in your solution in a consistent, elegant way.
Since certain tasks (in regard to data acess) have become so trivialized by the abstractions brought into the .net ecosystem, we can start DRYing our code across the tech stack pretty seamlessly. The repository pattern can be mirrored between the back-end and the front-end, which provides you a seamless approach to dealing with boring CRUD operations across layers of the tech stack.
[TableName("People")]
public class PersonModel : IStoredItem
{
public virtual int Id { get; set; }
[Searchable]
public virtual string? Name { get; set; } = string.Empty;
}public class PersonService : Service<PersonModel>
{
public PersonService(IDataStore<PersonModel> dataStore) : base(dataStore) { }
}[ApiController]
[Route("[controller]")]
public class PeopleController : ExtendedControllerBase<PersonModel>
{
public PeopleController(Service<PersonModel> service) : base(service) { }
}By subclassing the ExtendedControllerBase in your code, you get a fully-functioning CRUD+Search API for that model class generated automatically:
With these classes set up and registered in the DI container, you could now utilize Service in any project in the solution and perform CRUD and Search operations in an API controller, Blazor component, Xamarin/MAUI app, etc.
@foreach(var person in people)
{
<p>@person.Id - @person.Name</p>
}
<input type="text" @bind-value=nameSearch />
<button @onclick=Search>Search</button>
@code {
[Inject] Service<PersonModel>? service { get; set; }
private string nameSearch = string.Empty;
IEnumerable<PersonModel> people = new List<PersonModel>();
private async Task Search()
{
people = await service!.SearchAsync(nameSearch,
pagingOptions: default,
x => x.Name,
x => x.Id); // This won't get searched since the Id property on PersonModel doesn't have [Searchable]
}
}In order to make this work, we need to plug an IDataStore implemenation into the DI containers of each application. So far, there are two (2) built-in IDataStore implementations (more are coming): InMemoryDataStore and HttpClientDataStore
Note If you need an IDataStore for your database/data source, simply implement the IDataStore interface.
The InMemoryDataStore is an in-memory data store that can be used for mocking / testing.
The HttpClientDataStore is more interesting.
When using the Service class with HttpClientDataStore registered as the IDataStore, the Service will make HTTP requests to the appropriate controller methods behind the scenes.
If you need to add role authorization to any controller method, you can always override the method in your controller and add the [Authorize] attribute:
[Authorize]
public override Task<ActionResult> Upsert([FromBody] IEnumerable<PersonModel> items) => base.Upsert(items);It's important to note that in order for the system to automatically wire up and communicate with the API, the HttpClient must be properly configured to point to the API project, and your API project must allow CORS from your front-end applcation.
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri("https://localhost:7051/") }); // URL to the API project
builder.Services.AddWebApiRepository<PersonModel, PersonService>();
// The above line is a shortcut for:
// builder.Services.AddScoped<IDataStore<PersonModel>, HttpClientDataStore<PersonModel>>();
// builder.Services.AddScoped<Service<PersonModel>, PersonService>();builder.Services.AddSingleton<IDataStore<PersonModel>, InMemoryDataStore<PersonModel>>();
builder.Services.AddSingleton<Service<PersonModel>, PersonServiceMock>();
var MyAllowSpecificOrigins = "_myAllowSpecificOrigins";
builder.Services.AddCors(options =>
{
options.AddPolicy(
name: MyAllowSpecificOrigins,
policy =>
{
policy.WithOrigins("https://localhost:7220"); // URL to the front-end application that makes requests to this API
});
});
var app = builder.Build();
// Later on...
app.UseCors(MyAllowSpecificOrigins);Gets a single instance of T based on the id
Task<T> GetAsync
int idExample:
var item = await MyService.GetAsync(id);Gets a list of T items based on pagination details (get all)
Task<IEnumerable<T>> GetAsync
PagingInfo? pagingOptions = nullExample:
var items = await GetAsync(); // Get All
var items = await GetAsync(new PagingInfo { Page = 0, PageSize = 25 }); // Paging optionsGets a list of T items where the given property matches value
Task<IEnumerable<T>> GetAsync
Expression<Func<T, object?>> propertyExpr,
object value,
PagingInfo? pagingOptions = nullExample:
var items = await GetAsync(x => x.Name, "Moe"); // Get Any PersonModel items with the Name "Moe"Searches for a list of items where any of the given properties contain query
Note This will only search properties that are marked with the [Searchable] attribute.
Task<IEnumerable<T>> SearchAsync
string query,
PagingInfo? pagingOptions = null,
params Expression<Func<T, object>>[] propertiesToSearchExample:
var items = await SearchAsync("Moe", pagingOptions: default, x => x.Name, x => x.Id); // Searches Name and Id properties for "Moe"Searches for a list of items where each each property in SearchType is compared against the corresponding property in T
Note This will only search properties that have [Searchable] attribute. If the Entity type T doesn't contain a property inside your Search Model, then the search on that property will be skipped and no error will be thrown.
Task<IEnumerable<T>> SearchAsync<SearchType>
SearchType searchModel,
PagingInfo? pagingOptions = nullExample:
class PersonSearchModel
{
public string? Name { get; set; }
}
// Later...
<div class="form-group">
<label for="formName">Name</label>
<input id="formName" class="form-control" type="text" @bind-value=searchModel.Name />
</div>
<button class="btn btn-primary" @onclick=FormSearch>Search</button>
@code {
[Inject] Service<PersonModel>? service { get; set; }
private PersonSearchModel searchModel = new();
IEnumerable<PersonModel> results = new List<PersonModel>();
private async Task FormSearch()
{
results = await service!.SearchAsync(searchModel, pagingOptions: default);
}
}Adds a new record if the entity doesn't exist, otherwise it updates the existing record.
public Task<T> UpsertAsync
T entityExample:
var item = await UpsertAsync(new PersonModel { Name = "Moe" });Adds new records if the entities don't exist, otherwise it updates the existing records.
public Task<IEnumerable<T>> UpsertAsync
IEnumerable<T> itemsExample:
var items = await UpsertAsync(listOfItems);Delete item by Id
public Task<bool> DeleteAsync(int id)Example:
var deletedSuccessfully = await DeleteAsync(1); A side effect to the way this is designed is that debugging your data access layer while developing actually becomes much simpler.
If you override the OnException(Exception ex) method in your Service class and leave a breakpoint inside it, you will automatically hit the stop if a data access error occurs in any application in your solution during runtime. This is equivilent to setting a breakpoint in every catch(){ } statement in your Entity's repository.
Furthermore, if you set a breakpoint inside the HandleException method of an IDataStore, you'll hit a stop if a data access error occurs in any application for every single Entity in your solution during runtime. (as long as it's using that IDataStore) which is, a lot of power for one breakpoint. And is in my opinion, overall pretty cool.
public class PersonService : Service<PersonModel>
{
public PersonService(IDataStore<PersonModel> dataStore) : base(dataStore) { }
protected override Task OnException(Exception ex)
{
return base.OnException(ex);
}
}If you plan on using this as an out-of-the-box solution, I'd highly recommend forking the project instead of cloning it directly from here. This is very much so a work-in-progress, and it will likely change. This is not a stable, production-ready product yet, as ideas are being experimented with and improved upon. This project does not make any promises regarding security or stability of data access. You'll need to rely on applying security measures (auth, roles, etc) to the data access in API controllers and database operations as you normally would elsewhere.
This can become a stable production-ready product with the help of community contributions. If this seems like something you or your company could benefit from, feel free to get involved!
