Il Repository Pattern è un potente strumento per strutturare l’accesso ai dati in un’applicazione .NET. In questo tutorial, ti guiderò attraverso un’implementazione avanzata del pattern, creando un nuovo progetto. Potrai facilmente adattare questi passaggi al tuo progetto esistente.

Se preferisci vedere subito il codice completo, puoi trovare il progetto su GitHub: EF.RepositoryPattern.NET

Perché usare il repository pattern?

Il Repository Pattern offre diversi vantaggi:

  • Separazione delle preoccupazioni: Isola la logica di accesso ai dati dal resto dell’applicazione, rendendo il codice più pulito e manutenibile.
  • Testabilità migliorata: Facilita la scrittura di unit test permettendo di sostituire facilmente le implementazioni reali con mock.
  • Flessibilità e scalabilità: Consente di cambiare la fonte dei dati o aggiungere nuove entità senza modificare il codice client, rendendo l’applicazione più scalabile.

Implementazione

Creazione progetto

dotnet new webapi -n EF.RepositoryPattern.NET
cd EF.RepositoryPattern.NET

Dopo aver creato il progetto, organizza la struttura delle cartelle come mostrato nell’immagine.

Installazione dei pacchetti NuGet

Dopo aver creato il progetto, è necessario installare i seguenti pacchetti NuGet per supportare Entity Framework CoreSQLite e Serilog

dotnet add package Microsoft.EntityFrameworkCore -v 8.0.8
dotnet add package Microsoft.EntityFrameworkCore.Sqlite -v 8.0.8
dotnet add package Microsoft.EntityFrameworkCore.Sqlite.Core -v 8.0.8
dotnet add package Microsoft.EntityFrameworkCore.Tools -v 8.0.8
dotnet add package Serilog -v 4.0.1

Definizione delle interfacce

In Interfaces, crea i seguenti file:

IBaseEntity.cs

namespace EF.RepositoryPattern.NET.Interfaces;

public interface IBaseEntity;

Questa interfaccia vuota serve come marker per tutte le tue entità. Permette di vincolare i repository generici a lavorare solo con entità che implementano questa interfaccia.

IBaseRepository.cs

using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace EF.RepositoryPattern.NET.Interfaces;

/// <summary>
/// Base repository class providing common CRUD operations for entities.
/// </summary>
/// <typeparam name="TEntity">The type of entity managed by the repository.</typeparam>
public interface IBaseRepository<TEntity> where TEntity : IBaseEntity
{
    /// <summary>
    /// The UseLazyLoadingProxies property in Entity Framework is used to enable lazy loading of related entities.
    /// When this property is enabled, related entities are not automatically loaded
    /// when the main entity is loaded.
    /// </summary>
    /// <param name="useAsLazyLoadingProxies">Used to enable or disable lazy loading.</param>
    public void UseAsLazyLoadingProxies(bool useAsLazyLoadingProxies);

    /// <summary>
    /// Retrieves an IQueryable representing the given entity.
    /// </summary>
    /// <returns>An IQueryable of TEntity.</returns>
    IQueryable<TEntity> AsQueryable();

    /// <summary>
    /// Retrieves an IQueryable as no tracking representing the given entity.
    /// </summary>
    /// <returns>An IQueryable of TEntity.</returns>
    public IQueryable<TEntity> AsNoTracking();

    /// <summary>
    /// Retrieves all entities synchronously.
    /// </summary>
    /// <returns>An IEnumerable of TEntity containing all entities.</returns>
    IEnumerable<TEntity> GetAll();

    /// <summary>
    /// Retrieves all entities asynchronously.
    /// </summary>
    /// <param name="cancellationToken">The cancellation token.</param>
    /// <returns>An IEnumerable of TEntity containing all entities.</returns>
    Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default);

    /// <summary>
    /// Adds a new entity synchronously.
    /// </summary>
    /// <param name="entity">The entity to add.</param>
    void Add(TEntity entity);

    /// <summary>
    /// Adds a new entity asynchronously.
    /// </summary>
    /// <param name="entity">The entity to add.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    Task AddAsync(TEntity entity, CancellationToken cancellationToken = default);

    /// <summary>
    /// Adds a range of entities synchronously.
    /// </summary>
    /// <param name="entities">The entities to add.</param>
    void AddRange(IEnumerable<TEntity> entities);

    /// <summary>
    /// Adds a range of entities asynchronously.
    /// </summary>
    /// <param name="entities">The entities to add.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    Task AddRangeAsync(IEnumerable<TEntity> entities, CancellationToken cancellationToken = default);

    /// <summary>
    /// Updates an existing entity synchronously.
    /// </summary>
    /// <param name="oldEntity">The old entity.</param>
    /// <param name="newEntity">The new entity with updated values.</param>
    void Update(TEntity oldEntity, TEntity newEntity);

    /// <summary>
    /// Updates an existing entity synchronously.
    /// </summary>
    /// <param name="entity">The entity to update.</param>
    void Update(TEntity entity);

    /// <summary>
    /// Updates an existing entity asynchronously.
    /// </summary>
    /// <param name="entity">The entity to update.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default);

    /// <summary>
    /// Updates an existing entity asynchronously.
    /// </summary>
    /// <param name="oldEntity">The old entity.</param>
    /// <param name="newEntity">The new entity with updated values.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    Task UpdateAsync(TEntity oldEntity, TEntity newEntity, CancellationToken cancellationToken = default);

    /// <summary>
    /// Updates a range of entities synchronously.
    /// </summary>
    /// <param name="entities">The entities to update.</param>
    void UpdateRange(IEnumerable<TEntity> entities);

    /// <summary>
    /// Updates a range of entities asynchronously.
    /// </summary>
    /// <param name="entities">The entities to update.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    Task UpdateRangeAsync(IEnumerable<TEntity> entities, CancellationToken cancellationToken = default);

    /// <summary>
    /// Deletes an entity synchronously.
    /// </summary>
    /// <param name="entity">The entity to delete.</param>
    void Delete(TEntity entity);

    /// <summary>
    /// Deletes an entity asynchronously.
    /// </summary>
    /// <param name="entity">The entity to delete.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default);

    /// <summary>
    /// Deletes a range of entities synchronously.
    /// </summary>
    /// <param name="entities">The entities to delete.</param>
    void DeleteRange(IEnumerable<TEntity> entities);

    /// <summary>
    /// Deletes a range of entities asynchronously.
    /// </summary>
    /// <param name="entities">The entities to delete.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    Task DeleteRangeAsync(IEnumerable<TEntity> entities, CancellationToken cancellationToken = default);

    /// <summary>
    /// Truncates the entity synchronously.
    /// </summary>
    void Truncate();

    /// <summary>
    /// Truncates the entity asynchronously.
    /// </summary>
    /// <param name="cancellationToken">The cancellation token.</param>
    Task TruncateAsync(CancellationToken cancellationToken = default);
}

Questa interfaccia generica definisce le operazioni base che ogni repository dovrebbe supportare. L’uso di generics permette di riutilizzare questa interfaccia per diverse entità.

ICustomersRepository.cs

namespace EF.RepositoryPattern.NET.Interfaces;

public interface ICustomersRepository<T> : IBaseRepository<T> where T : IBaseEntity;

Questa interfaccia estende IBaseRepository per l’entità dei Customers. È possibile registrare CustomersRepository anche utilizzando IBaseRepository invece di ICustomersRepository.

Tuttavia, è importante notare che se ci sono più implementazioni registrate con la stessa interfaccia, l’ultima registrata sovrascrive le precedenti. Quindi, se si hanno più repository che implementano IBaseRepository, come ad esempio OrdersRepository e ProductsRepository, l’ultimo registrato sarà quello effettivamente utilizzato per tutte le dipendenze di IBaseRepository.

Per evitare questo comportamento e assicurarsi che ogni implementazione sia correttamente associata alla propria interfaccia, è consigliabile creare un’interfaccia specifica per ogni repository, come fatto con ICustomersRepository. In questo modo, si può registrare ogni repository con la sua interfaccia dedicata.

Implementazione del repository base

In Repositories, crea i seguenti file:

BaseRepository.cs

#region Usings

using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using EF.RepositoryPattern.NET.Interfaces;
using Microsoft.EntityFrameworkCore;
using Serilog;

#endregion

namespace EF.RepositoryPattern.NET.Repositories;

/// <summary>
/// Base repository class providing common CRUD operations for entities.
/// </summary>
/// <typeparam name="TEntity">The type of entity managed by the repository.</typeparam>
/// <typeparam name="TContext">The type of the DbContext used by the repository.</typeparam>
public abstract class BaseRepository<TEntity, TContext>
    where TEntity : class, IBaseEntity
    where TContext : DbContext
{
    private readonly TContext _dbContext;
    private bool? _useAsLazyLoadingProxies;

    /// <summary>
    /// Constructs an instance of the base repository with the provided DbContext.
    /// </summary>
    /// <param name="dbContext">The DbContext instance.</param>
    protected BaseRepository(TContext dbContext)
    {
        _dbContext = dbContext;
        if (_useAsLazyLoadingProxies.HasValue)
            _dbContext.ChangeTracker.LazyLoadingEnabled = _useAsLazyLoadingProxies.Value;
    }

    /// <summary>
    /// The UseLazyLoadingProxies property in Entity Framework is used to enable lazy loading of related entities.
    /// When this property is enabled, related entities are not automatically loaded
    /// when the main entity is loaded.
    /// </summary>
    /// <param name="useAsLazyLoadingProxies">Used to enable or disable lazy loading.</param>
    public void UseAsLazyLoadingProxies(bool useAsLazyLoadingProxies)
    {
        _useAsLazyLoadingProxies = useAsLazyLoadingProxies;
    }

    /// <summary>
    /// Retrieves an IQueryable representing the given entity.
    /// </summary>
    /// <returns>An IQueryable of TEntity.</returns>
    public IQueryable<TEntity> AsQueryable()
    {
        Log.Logger.Information("Retrieving queryable for '{TEntity}'.", typeof(TEntity).Name);
        return _dbContext.Set<TEntity>();
    }

    /// <summary>
    /// Retrieves an IQueryable as no tracking representing the given entity.
    /// </summary>
    /// <returns>An IQueryable of TEntity.</returns>
    public IQueryable<TEntity> AsNoTracking()
    {
        Log.Logger.Information("Retrieving queryable for '{TEntity}'.", typeof(TEntity).Name);
        return _dbContext.Set<TEntity>().AsNoTracking();
    }

    /// <summary>
    /// Retrieves all entities synchronously.
    /// </summary>
    /// <returns>An IEnumerable of TEntity containing all entities.</returns>
    public virtual IEnumerable<TEntity> GetAll()
    {
        Log.Logger.Information("Retrieving all records from '{TEntity}'.", typeof(TEntity).Name);
        return _dbContext.Set<TEntity>();
    }

    /// <summary>
    /// Retrieves all entities asynchronously.
    /// </summary>
    /// <param name="cancellationToken">The cancellation token.</param>
    /// <returns>An IEnumerable of TEntity containing all entities.</returns>
    public virtual Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default)
    {
        Log.Logger.Information("Retrieving all records from '{TEntity}'.", typeof(TEntity).Name);
        return Task.FromResult<IEnumerable<TEntity>>(_dbContext.Set<TEntity>());
    }

    /// <summary>
    /// Adds a new entity synchronously.
    /// </summary>
    /// <param name="entity">The entity to add.</param>
    public virtual void Add(TEntity entity)
    {
        Log.Logger.Information("Adding record to '{TEntity}'.", typeof(TEntity).Name);
        _dbContext.Set<TEntity>().Add(entity);
        _dbContext.SaveChanges();
    }

    /// <summary>
    /// Adds a new entity asynchronously.
    /// </summary>
    /// <param name="entity">The entity to add.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    public virtual async Task AddAsync(TEntity entity, CancellationToken cancellationToken = default)
    {
        Log.Logger.Information("Adding record to '{TEntity}'.", typeof(TEntity).Name);
        await _dbContext.Set<TEntity>().AddAsync(entity, cancellationToken);
        await _dbContext.SaveChangesAsync(cancellationToken);
    }

    /// <summary>
    /// Adds a range of entities synchronously.
    /// </summary>
    /// <param name="entities">The entities to add.</param>
    public virtual void AddRange(IEnumerable<TEntity> entities)
    {
        Log.Logger.Information("Adding range records to '{TEntity}'.", typeof(TEntity).Name);
        _dbContext.Set<TEntity>().AddRange(entities);
        _dbContext.SaveChanges();
    }

    /// <summary>
    /// Adds a range of entities asynchronously.
    /// </summary>
    /// <param name="entities">The entities to add.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    public virtual async Task AddRangeAsync(IEnumerable<TEntity> entities, CancellationToken cancellationToken = default)
    {
        Log.Logger.Information("Adding range records to '{TEntity}'.", typeof(TEntity).Name);
        await _dbContext.Set<TEntity>().AddRangeAsync(entities, cancellationToken);
        await _dbContext.SaveChangesAsync(cancellationToken);
    }

    /// <summary>
    /// Updates an existing entity synchronously.
    /// </summary>
    /// <param name="entity">The entity to update.</param>
    public virtual void Update(TEntity entity)
    {
        Log.Logger.Information("Updating record from '{TEntity}'.", typeof(TEntity).Name);
        _dbContext.Update(entity);
        _dbContext.SaveChanges();
    }

    /// <summary>
    /// Updates an existing entity synchronously.
    /// </summary>
    /// <param name="oldEntity">The old entity.</param>
    /// <param name="newEntity">The new entity with updated values.</param>
    public virtual void Update(TEntity oldEntity, TEntity newEntity)
    {
        Log.Logger.Information("Updating old record from '{TEntity}'.", typeof(TEntity).Name);
        _dbContext.Entry(oldEntity).State = EntityState.Detached;
        _dbContext.Update(newEntity);
        _dbContext.SaveChanges();
    }

    /// <summary>
    /// Updates an existing entity asynchronously.
    /// </summary>
    /// <param name="entity">The entity to update.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    public virtual async Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default)
    {
        Log.Logger.Information("Updating record from '{TEntity}'.", typeof(TEntity).Name);
        _dbContext.Update(entity);
        await _dbContext.SaveChangesAsync(cancellationToken);
    }

    /// <summary>
    /// Updates an existing entity asynchronously.
    /// </summary>
    /// <param name="oldEntity">The old entity.</param>
    /// <param name="newEntity">The new entity with updated values.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    public virtual async Task UpdateAsync(TEntity oldEntity, TEntity newEntity, CancellationToken cancellationToken = default)
    {
        Log.Logger.Information("Updating old record from '{TEntity}'.", typeof(TEntity).Name);
        _dbContext.Entry(oldEntity).State = EntityState.Detached;
        _dbContext.Update(newEntity);
        await _dbContext.SaveChangesAsync(cancellationToken);
    }

    /// <summary>
    /// Updates a range of entities synchronously.
    /// </summary>
    /// <param name="entities">The entities to update.</param>
    public virtual void UpdateRange(IEnumerable<TEntity> entities)
    {
        Log.Logger.Information("Updating range records from '{TEntity}'.", typeof(TEntity).Name);
        _dbContext.Set<TEntity>().UpdateRange(entities);
        _dbContext.SaveChanges();
    }

    /// <summary>
    /// Updates a range of entities asynchronously.
    /// </summary>
    /// <param name="entities">The entities to update.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    public virtual async Task UpdateRangeAsync(IEnumerable<TEntity> entities, CancellationToken cancellationToken = default)
    {
        Log.Logger.Information("Updating range records from '{TEntity}'.", typeof(TEntity).Name);
        _dbContext.Set<TEntity>().UpdateRange(entities);
        await _dbContext.SaveChangesAsync(cancellationToken);
    }

    /// <summary>
    /// Deletes an entity synchronously.
    /// </summary>
    /// <param name="entity">The entity to delete.</param>
    public virtual void Delete(TEntity entity)
    {
        Log.Logger.Information("Deleting record from '{TEntity}'.", typeof(TEntity).Name);
        _dbContext.Set<TEntity>().Remove(entity);
        _dbContext.SaveChanges();
    }

    /// <summary>
    /// Deletes an entity asynchronously.
    /// </summary>
    /// <param name="entity">The entity to delete.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    public virtual async Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default)
    {
        Log.Logger.Information("Deleting record from '{TEntity}'.", typeof(TEntity).Name);
        _dbContext.Set<TEntity>().Remove(entity);
        await _dbContext.SaveChangesAsync(cancellationToken);
    }

    /// <summary>
    /// Deletes a range of entities synchronously.
    /// </summary>
    /// <param name="entities">The entities to delete.</param>
    public virtual void DeleteRange(IEnumerable<TEntity> entities)
    {
        Log.Logger.Information("Deleting range records from '{TEntity}'.", typeof(TEntity).Name);
        _dbContext.Set<TEntity>().RemoveRange(entities);
        _dbContext.SaveChanges();
    }

    /// <summary>
    /// Deletes a range of entities asynchronously.
    /// </summary>
    /// <param name="entities">The entities to delete.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    public virtual async Task DeleteRangeAsync(IEnumerable<TEntity> entities, CancellationToken cancellationToken = default)
    {
        Log.Logger.Information("Deleting range records from '{TEntity}'.", typeof(TEntity).Name);
        _dbContext.Set<TEntity>().RemoveRange(entities);
        await _dbContext.SaveChangesAsync(cancellationToken);
    }

    /// <summary>
    /// Truncates the entity synchronously.
    /// </summary>
    public virtual void Truncate()
    {
        Log.Logger.Information("Deleting range records from: {TEntity}", typeof(TEntity).Name);
        _dbContext.Set<TEntity>().RemoveRange(_dbContext.Set<TEntity>());
        _dbContext.SaveChanges();
    }

    /// <summary>
    /// Truncates the entity asynchronously.
    /// </summary>
    /// <param name="cancellationToken">The cancellation token.</param>
    public virtual async Task TruncateAsync(CancellationToken cancellationToken = default)
    {
        Log.Logger.Information("Deleting range records from: {TEntity}", typeof(TEntity).Name);
        _dbContext.Set<TEntity>().RemoveRange(_dbContext.Set<TEntity>());
        await _dbContext.SaveChangesAsync(cancellationToken);
    }
}

Questa classe astratta fornisce un’implementazione di base per IBaseRepository. L’uso di due generici (TEntity e TContext) permette di utilizzare lo stesso repository base con diversi tipi di entità e contesti di database.

CustomersRepository.cs

using EF.RepositoryPattern.NET.Contexts;
using EF.RepositoryPattern.NET.Interfaces;

namespace EF.RepositoryPattern.NET.Repositories;

public class CustomersRepository<TEntity>(CustomersDbContext context)
    : BaseRepository<TEntity, CustomersDbContext>(context), ICustomersRepository<TEntity>
    where TEntity : class, IBaseEntity;

Questa classe concreta estende BaseRepository specificamente per l’entità Customers.

Definizione del contesto

In Contexts, crea il seguente file:

CustomersDbContext.cs

namespace EF.RepositoryPattern.NET.Contexts;

public class CustomersDbContext(DbContextOptions<CustomersDbContext> dbContextOptions)
    : DbContext(dbContextOptions)
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<CustomersEntity>(entity =>
        {
            entity.Property(e => e.Id).ValueGeneratedOnAdd();
        });
    }
}

Il contesto in Entity Framework rappresenta una sessione con il database, fungendo da ponte tra il tuo codice e il database stesso.

Questo contesto specifico per i Customers configura come l’entità CustomersEntity deve essere mappata nel database.

Creazione dell’entità

In Entities, crea il seguente file:

CustomersEntity.cs

namespace EF.RepositoryPattern.NET.Entities;

public class CustomersEntity : IBaseEntity
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }
}

Questa classe definisce la struttura della tabella Customers nel database, specificando le colonne e le loro proprietà.

Creazione API

In Controllers, crea il seguente file:

CustomersController.cs

namespace EF.RepositoryPattern.NET.Controllers;

[ApiController]
[Route("[controller]/[action]")]
public class CustomersController(ICustomersRepository<CustomersEntity> customersRepository) : ControllerBase
{
    [HttpGet]
    public async Task<IActionResult> GetCustomersAsync()
    {
        return Ok(await customersRepository.GetAllAsync());
    }

    [HttpPost]
    public async Task<IActionResult> CreateCustomersAsync(string firstName, string lastName, string email)
    {
        var newCustomer = new CustomersEntity
        {
            FirstName = firstName,
            LastName = lastName,
            Email = email
        };
        await customersRepository.AddAsync(newCustomer);

        return Ok(newCustomer);
    }
}

Attraverso questo controller potrai testare tutte le operazioni CRUD su Customers tramite API REST.

Registrazione dei servizi

Program.cs

builder.Services.AddDbContext<CustomersDbContext>(options =>
    options.UseSqlite("Data Source=Customers.db;"));
builder.Services.AddScoped(typeof(ICustomersRepository<>), typeof(CustomersRepository<>));

Questa configurazione registra il contesto del database e il repository dei Customers nel container di dipendenze.

A partire da .NET 8, i template ASP.NET Core Web API non includono più automaticamente la registrazione dei controller e il mapping delle rotte dei controller. Quindi, è necessario aggiungere manualmente queste configurazioni nel file Program.cs. Assicurati di includere le seguenti linee.

// Nel metodo ConfigureServices o nella parte di configurazione dei servizi
builder.Services.AddControllers();

// Nella parte di configurazione del middleware
app.MapControllers();

Migrazione e test API

Prima di testare le API, è necessario creare e applicare una migrazione per configurare il database. Esegui questo comando dalla cartella del progetto:

dotnet ef migrations add --project EF.RepositoryPattern.NET/EF.RepositoryPattern.NET.csproj --startup-project EF.RepositoryPattern.NET/EF.RepositoryPattern.NET.csproj --context EF.RepositoryPattern.NET.Contexts.CustomersDbContext --configuration Release --verbose Initial --output-dir Migrations

Per assicurarti che la migrazione venga applicata automaticamente ad ogni avvio dell’applicazione, aggiungi questo codice in Program.cs:

using (var serviceScope = app.Services.CreateScope())
{
    var context = serviceScope.ServiceProvider.GetService<CustomersDbContext>();
    context!.Database.Migrate();
}

Ora che hai completato l’implementazione, puoi avviare il progetto e testare il corretto funzionamento.

Creazione customer

curl --location --request POST 'http://localhost:5000/customers/createcustomers?firstName=John&lastName=Doe&email=john.doe@example.com'

Elenco customers

curl --location 'http://localhost:5000/customers/getcustomers

Conclusione

Il repository pattern in .NET che hai implementato offre una solida base per gestire l’accesso ai dati nelle tue applicazioni. Questo potente strumento ti permette di separare le responsabilità, migliorare la testabilità e ottenere maggiore flessibilità nel gestire diverse fonti di dati.

Ricorda, come ogni pattern architetturale, l’efficacia del repository pattern dipende da come lo applichi. Valuta sempre attentamente il contesto del tuo progetto prima di decidere se e come implementarlo.