Design pattern for Database Context

Design pattern for Database Context

I have a small library system application which I want to implement CRUD operation on Books and other related services.

When choosing the pattern for my data access layer, my initial thought was that a singleton pattern would ensure only one object instance and provide a global access point to that instance. I believed that by reducing repeated connection initialization, I could lower the operation overhead.

Instead of using the traditional static class implementation for Singleton, .NET 8 offers singleton dependency injection right out of the box, which is convenient. My initial layers include the Controller, which communicates with the Service layer where most of the business logic resides, followed by the data access components: Context, Entity Framework, and finally the database..

Here is my context class - LibraryDBContext.cs

using library_system.Entities;
using Microsoft.EntityFrameworkCore;

namespace library_system.Context
{
    public class LibraryDBContext : DbContext
    {
        public DbSet<Book> Books { get; set; }
        public DbSet<LibraryMember> LibraryMembers { get; set; }

        public LibraryDBContext(DbContextOptions<LibraryDBContext> options)
            : base(options)
        {
        }
    }
}

Then in my Program.cs I only need to register this database context with the service container using the Singleton lifetime identifier.

builder.Services.AddDbContext<LibraryDBContext>(options =>
        options.UseInMemoryDatabase(builder.Configuration.GetConnectionString("LibraryDB")), ServiceLifetime.Singleton);

Once the context is registered, I can freely use it through Dependency Injection in any of my service such as:

public class BookService : IBookService
    {
        private readonly LibraryDBContext _dbContext;

        public BookService(LibraryDBContext dbContext)
        {
            _dbContext = dbContext;
        }

        public async Task<bool> AddAsync(Book book)
        {
            _dbContext.Books.Add(book);
            var result = await _dbContext.SaveChangesAsync();

            return result > 0;
        }
    }

This worked when I tested my API calls using Postman. We could leave it just like that and the application seems to work right?

Nope, I realized the DbContext is not thread-safe and will lead to concurrency issues or data corruption when under loads because DbContext keeps track of changes to the entities so if multiple requests try to modify the entities at the same time will become an issue. So why I take time to write this out? I want to show my train of thought and at the same time see how Singleton can be implemented in .NET Core.

The proper solution to this would be implementing the Repository and Unit of Work patterns. These patterns are often used together to abstract the data layer in an application, making it more manageable, testable, and maintainable.

The layers are presented as:

Repository Pattern

Repository Pattern: It provides an abstraction layer over the data access layer. It encapsulates the logic required to access data sources, providing a more object-oriented view of the persistence layer. Repositories work with the domain entities and hide the details of how the data is stored or retrieved from the database.

Benefits:

  • Decoupling: It decouples the business logic from the data access logic.

  • Testability: Makes unit testing easier due to the separation of concerns.

  • Maintainability: Changes to the database or the data model affect only the repositories and not the rest of the application.

Unit of Work Pattern

The Unit of Work pattern keeps track of everything we do during a business transaction that can affect the database. When we're done, it figures out everything that needs to be done to alter the database as a result of our work. It's often used together with the Repository pattern.

Benefits:

  • Transaction Management: It manages transactions, ensuring that data changes are consistent and isolated from each other.

  • Change Tracking: Keeps track of changes during a transaction and groups them into a single commit or rollback operation.

  • Simplifies Complex Operations: Simplifies complex operations involving multiple steps or data manipulations by coordinating the write out of changes and the resolution of concurrency problems.

The layers are presented as:

To apply this pattern, I need to register all the classes in the service collection so they can be used for Dependency Injection later.

builder.Services.AddDbContext<LibraryDbContext>(options =>
    options.UseInMemoryDatabase("LibraryDB"));

// Add services to the container.
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddScoped<IBookRepository, BookRepository>();
builder.Services.AddScoped<IBookService, BookService>();

I add a new class call BookRepository which interact directly with my LibraryDbContext similar to how my BookService class used to do.

    public class BookRepository(LibraryDbContext dbContext) : IBookRepository
    {
        private readonly LibraryDbContext _dbContext = dbContext;

        public void Add(Book book)
        {
            _dbContext.Books.Add(book);
        }
    }

the IBookRepository class in the code abstracts how the DbContext is used to manage the data layer for books in the application. By using this repository pattern, we are encapsulating the logic for accessing the database (CRUD operations in this case) within a specific class that implements a defined interface (IBookRepository).

Also, by abstracting the class, we can now easily use dependency injection through IBookRepository to write our unit tests. This allows us to mock the output of each method in our repository.

The UnitOfWork class in the application serves as a central point for managing transactions across multiple repositories or data operations. By using a UnitOfWork, I can ensure that operations involving multiple steps or affecting multiple entities are completed successfully as a whole, or not at all, maintaining data integrity.

The Dispose method in the UnitOfWork class is crucial for managing the lifecycle of resources, particularly the DbContext in Entity Framework (EF) applications. Implementing IDisposable and providing a Dispose method allows our UnitOfWork to properly release unmanaged resources and dispose of managed resources when they are no longer needed

    public class UnitOfWork(LibraryDbContext dbContext, IBookRepository bookRepository) : IUnitOfWork
    {
        private readonly LibraryDbContext _dbContext = dbContext;

        public IBookRepository Books { get; private set; } = bookRepository;

        public async Task<int> CompleteAsync()
        {
            return await _dbContext.SaveChangesAsync();
        }

        public void Dispose()
        {
            _dbContext.Dispose();
        }
    }

The UnitOfWork pattern, particularly with a method like CompleteAsync, allow us to perform multiple operations on different entities, such as a Book object, within a single transactional context. These changes won't be persisted to the database until we explicitly call CompleteAsync. It also allow us to add other repositories instead of just Books if we want to down the line.

Lastly our BookService class now can interact directly with UnitOfWork

public class BookService(IUnitOfWork unitOfWork) : IBookService
{
    private readonly IUnitOfWork _unitOfWork = unitOfWork;

    public async Task<bool> AddAsync(Book book)
    {
        _unitOfWork.Books.Add(book);
        var result = await _unitOfWork.CompleteAsync();
        return result > 0;
    }
}

Finally, with all of these scaffoldings in place, we can now try to test out the API in Postman.

In conclusion, we discussed the implementation of a data access layer in a .NET 8 application, initially considering the Singleton pattern but recognizing its thread-safety limitations. We explore the use of Repository and Unit of Work patterns for improved decoupling, testability, and transactional integrity. Examples of code implementation along with dependency injection configurations, encapsulated data access logic, and transaction management techniques are provided to enhance scalability and maintainability.

Code repo: https://github.com/amphan613/library-managment-system/tree/00d61ae0b6943850775b9599b69a42eb0a6ffcd0