Symmetrical Repository Pattern - Data Access Made Easy In .NET

The Repository Pattern is a great design pattern that allows us to separate a data source from the mechanism our application uses to interact with the data.

When done properly, it allows us to leverage the Dependency Inversion Principle to our advantage, and data sources such as databases, text files, in-memory mocked data, etc, can be swapped in and out without changing anything about the applications that use the data.

When the entire tech stack of a product is written in one unified language ecosystem (such as .net), we can start abstracting outwards with some traditional design patterns, across application layers/boundaries—across different parts of the stack. This wasn’t so possible in the past when every layer of the stack and every piece of infrastructure used a different language and ecosystem. Times are changing fast in this regard.

Reusing code from front-end to back-end doesn't need to stop at sharing models in a shared project. Since Dependency Injection is so first-class in .net, for instance, the repository pattern proves to be an excellent way to handle data access in a .net application. It has proven its worth in several production full-stack .net applications for me thus far, and lends to being a fantastic developer experience. But, the more full-stack .net applications I’ve worked on, the more I noticed a pattern develop.

The Symmetrical Repository Pattern

The repository pattern could essentially be mirrored in the front-end, and used in the same way as it is in the back-end. So in a Blazor component, for instance, you could @inject a repository that (under the hood) makes HTTP requests to your API, instead of communicating with a database.

Every time I worked on a project from scratch to deal with this scenario, the pattern made itself more and more clear. Essentially, the front-end and back-end had a lot of repeated patterns in the code. They both had repositories and both changed at the same time, and they both dealt with error handling the same way. This is a perfect candidate for something to be abstracted away (to keep the code DRY.)

In fact, over the past few production full-stack .net applications I've worked on, this pattern was looking me straight in the eye the whole time. I'm calling it the Symmetrical Repository pattern here, because it's really just a way to apply the Repository pattern across imaginary boundaries as long as the data access is symmetrical "in both directions". Maybe it isn’t a new pattern, maybe it could just be considered a new way to apply an existing pattern. Nonetheless there are some restrictions we need to set in place to make it work, so I think it’s appropriate to separate this idea from a traditional Repository pattern, regardless of what you may call it.

I first saw this idea, to use Dependency Injection to cross project boundaries, in Xamarin.Forms, where platform-dependent classes could be injected across projects. The Shared XAML UI project could ask the DI container for a resource, and in the *Android* and *iOS* project you would register the platform-specific resource.

Making a mirror of the Repository Pattern

Since certain tasks (in regard to data access) 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 tedius CRUD operations across layers of the tech stack.

As you can see, both the back-end and the front-end can ask for the exact same repository type. This creates a complete symmetry between data access in the front-end and back-end, while still allowing for complete flexibility of said data access through the IDataStores, which can be extended and overridden.

This can be achieved by a combination of,

  • Multiple layers of indirection
  • The Template Method Pattern (a.k.a. The “Holywood Principle”) for error handling
  • Reflection and attributes/annotations for wiring up classes
  • And of course, the Repository Pattern.

Additionally, one of the patterns that pops up on both sides of the tech stack is try/catch statements being littered all over the Repositories and API Controllers. This can be entirely abstracted away using the Template Method pattern, and that’s why it is included in the above list.

Instead of consuming a data source from within the repository pattern directly, the Repository is a generic abstract base class (called Service in my implementation) that holds an instance of the real repository via DI.

In this abstract base class, we will implement the Template Method Pattern by defining public methods for all the CRUD operations,

public Task <T> GetAsync(int id) {
    return Try(async () => await OnGet(id));
}
protected Task HandleException(Exception ex) {
    return OnException(ex);
}

In the public methods, you can wrap calls to the inner virtual methods in try/catch statements. When an error is caught, invoke a virtual method OnError(Exception e)

protected async Task <U> Try <U> (Func < Task <U>> getter) where U: new() {
    try {
        return await getter();
    } catch (Exception ex) {
        await HandleException(ex);
        return new();
    }
}
protected async Task <InterfaceType> Try <InterfaceType, ConcreteType> (Func <Task<InterfaceType>> getter)
where ConcreteType: InterfaceType, new() {
    try {
        return await getter();
    } catch (Exception ex) {
        await HandleException(ex);
        return new ConcreteType();
    }
}

These are generic helper methods I’ve developed over time to wrap error-prone code in try/catch statements. It offloads the error handling to somewhere else, and uses some C# generic magic to never return null. Another interesting point about these helper methods is, you can keep breakpoints inside those two catch(...) statements the entire time you're programming/testing. Any time there is an exception anywhere in your data access, you will stop at one of those breakpoints so you can figure out what's going on faster. In my opinion, this accelerates the development cycle of new features.

Feel free to take it and use it if you find it useful.

Anyway, then we define virtual methods that can be overridden by the concrete Service classes. In the default implementation of the virtual methods, we invoke the appropriate operations from the underlying IDataStore instance.

protected virtual Task<T> OnGet(int id) => DataStore.GetAsync(id);
protected virtual Task OnException(Exception ex) => DataStore.HandleException(ex);

This OnException trick allows us to offload the exception handling to the DataStore, which will be the so-called “platform-dependent” code that actually handles the data access. This is where you want to log errors or display something to a user, in a UI repository.

To make this work with APIs, it was a little tricky to figure out. But then I realized that if we enforce a standard in our APIs using syntax, we can map those endpoints to HTTP requests in the UI repository.

Of course, when writing a good abstraction we shouldn’t rely on the hopes that the programmer using this code will follow the standard! So, we can enforce it in the syntax using interfaces. (well, in this case, an abstract base class.)

public abstract class ExtendedControllerBase < T > : ControllerBase
where T: IStoredItem, new() {
    protected readonly Service < T > Service;
    public ExtendedControllerBase(Service < T > service) {
            this.Service = service;
        }
        [HttpGet]
        [Route("find/{propertyName}/{value}")]
    public virtual async Task < ActionResult > Get([FromRoute] string propertyName, [FromRoute] string value) {
            return Ok(await Service.GetAsync(propertyName, value));
        }
        [HttpGet]
    public virtual async Task < ActionResult > Get(int page = 0, int pageSize = 10) {
            return Ok(await Service.GetAsync(page, pageSize));
        }
        [HttpDelete]
    public virtual async Task < ActionResult > Delete(int id) {
            var result = await Service.DeleteAsync(id);
            if (result) {
                return Ok(result);
            } else {
                return BadRequest();
            }
        }
        [HttpPut]
    public virtual async Task < ActionResult > Upsert([FromBody] IEnumerable < T > items) {
        if (items == null || !items.Any()) {
            return BadRequest("Invalid item passed");
        }
        return Ok(await Service.AddOrUpdateAsync(items));
    }
}

Now, if you need to make a controller that maps to a Model type and will work with our HttpClientDataStore, we just have to make an empty class that inherits from that and provides the Model type.

[ApiController]
[Route("[controller]")]
public class PeopleController: ExtendedControllerBase < IPersonModel > {
    public PeopleController(Service < IPersonModel > service): base(service) {}
}

Now the HttpClientDataStore which will act as the “inner repository” for the UI project,

public class HttpClientDataStore < T > : DataStore < T > , IDataStore < T > where T: IStoredItem, new() {
        private readonly HttpClient client;
        private readonly string controllerName = string.Empty;
        public HttpClientDataStore(HttpClient client) {
            this.client = client;
            controllerName = GetTableName(typeof(T));
        }
        public async Task < T > AddOrUpdateAsync(T item) {
            var response = await client.PutAsJsonAsync($ "{controllerName}/", new List < T > {
                item
            });
            if (response.IsSuccessStatusCode) {
                return item;
            } else {
                return default !;
            }
        }
        public async Task < IEnumerable < T >> AddOrUpdateAsync(IEnumerable < T > items) {
            var response = await client.PutAsJsonAsync($ "{controllerName}/", items);
            if (response.IsSuccessStatusCode) {
                return items;
            } else {
                return default !;
            }
        }
        public async Task < bool > DeleteAsync(int id) {
            var response = await client.DeleteAsync($ "{controllerName}?id={id}");
            return response.IsSuccessStatusCode;
        }
        public async Task < T > GetAsync(int id) {
            var response = await client.GetFromJsonAsync < T > ($ "{controllerName}/Id/{id}");
            return response!;
        }
        public async Task < IEnumerable < T >> GetAsync(string propertyName, object value) {
            var uri = $ "{controllerName}/find/{propertyName}/{value}";
            var response = await client.GetFromJsonAsync < IEnumerable < T >> (uri);
            return response ?? new List < T > ();
        }
        public async Task < IEnumerable < T >> GetAsync(int page = 0, int pageSize = 15) {
            var response = await client.GetFromJsonAsync < IEnumerable < T >> ($ "{controllerName}?page={page}&pageSize={pageSize}");
            return response ?? new List < T > ();
        }
        public Task HandleException(Exception ex) {
            // TODO: add logging here
            Console.WriteLine(ex.Message);
            return Task.CompletedTask;
        }

This class can be registerd as the IDataStore type in the UI project, and then when you inject a Service, it will make HTTP requests under the hood when accessing data. Additionally, you can add caching in this DataStore layer, if using something like a Redis cache, localStorage caching on a web app, etc.

This implementation I’m demonstrating relies on some neat Reflection tricks to do property-based data retrieval and naming API endpoints from Attributes/class names,

public abstract class DataStore<T>
{
    protected (Type, object) GetPropertyValue(object item, string propertyName)
    {
        var properties = typeof(T).GetProperties();
        var prop = properties.FirstOrDefault(p => p.Name == propertyName);
        if (prop is null)
        {
            throw new ArgumentException("Invalid property name was provided.");
        }
        return (prop.PropertyType, prop!.GetValue(item)!);
    }
    protected bool ComparePropertyWithValue(object item, string propertyName, object value)
    {
        var (type, result) = GetPropertyValue(item, propertyName);
        var val1 = Convert.ChangeType(result, type);
        var val2 = Convert.ChangeType(value, type);
        return result is not null && val1.Equals(val2);
    }
    public string GetTableName(Type type)
    {
        var attribute = type.GetCustomAttribute(typeof(TableNameAttribute));
        if(attribute is not null)
        {
            return ((TableNameAttribute)attribute).Name;
        }
        return type.Name;
    }
}

If you want to see the entire implementation I have of this idea so far, I have a ..ahem, repository on my GitHub of the complete solution—written in the form of a reusable library. If there isn’t a NuGet package of this solution by the time this article is published, you can download the code from the GitHub link below and just add the DataLib.Core project and DataLib.Api projects to your solution. Note I will be using this project to handle data access in my personal .net projects, so it may change and evolve over time accordingly.

(https://github.com/SeanCPP/sdotcode-DataLib) Feel free to report any bugs or contribute pull requests to this project.

At the end of the day, this is what data access looks like using this approach:

(Blazor)

@code {
    [Inject] Service<IPersonModel>? service {get; set;}
    IEnumerable<IPersonModel> people = new List<IPersonModel>();
    protected override async Task OnInitializedAsync()
    {
        people = await service!.GetAsync();
    }
}

Where’s that data coming from? The UI doesn’t care. In my example app, it is using the HttpClientDataStore implementation to make HTTP requests. Cool!

Here’s the controller it’ll wire up to automatically:

[ApiController]
[Route("[controller]")]
public class PeopleController : ExtendedControllerBase<IPersonModel>
 {
   public PeopleController(Service<IPersonModel> service) : base(service)
   {
   }
}

If you need to add authorization, you can override any of the endpoint methods and add [Authorize] to it.

But ultimately, the point is: see how both sides (front end, and backend API) are requesting the same thing?

Service<IPersonModel>

And, those are actually the same thing being requested by both projects. In my example app, the API is wired up to an InMemoryDataStore, but a postgres or MSSQL adapter could trivially be introduced.

Here are some of my closing thoughts about this technique:

Pros

  1. If you are the only developer and you are working in a full-stack modern .net codebase, you can avoid tons of boilerplate by making your repositories symmetrical.
  2. Avoid repeated bugs related to data access across the codebase. All of the bugs will be concentrated in the generic mechanisms of the data access layer (DAL). If there are bugs in my implementation, fixing the bugs in the generic classes will cascade the fixes to the other parts of my codebase.
  3. Error handling can be centralized in one place and errors can be processed differently depending on whether the DataStore is in the front-end or back-end.
  4. No try/catch statements anywhere in sight
  5. Service classes can be generated automatically by making a code generation tool. I have plans of making such a tool for my own implementation.
  6. For model/data driven “spreadsheet” type of applications, this can remove lots of boilerplate.

Cons

  1. If your data access requirements can’t be expressed by basic REST/Crud operations on data models, then this will absolutely not be a good solution.
  2. See: Point #2 in Pros. See, if you have a bug in the generic data access layer, you will likely have this bug in every part of your tech stack, for every model type.

In conclusion, I think this is a very powerful technique for avoiding DAL boilerplate in a full-stack .net solution, and I will keep iterating on the idea as I gain more experience working with this type of code. Let me know what you think, and if there are any obvious pitfalls to this solution that I've missed.

Link to the github project: (https://github.com/SeanCPP/sdotcode-DataLib)