Dans cet article nous allons aborder plusieurs sujets qui permettront d’avoir une première solution clé en main pour ce qui est de la gestion de nos données avec Entity Framework.

Dans un premier temps nous rappellerons les principes d’Entity Framework, du pattern Repository et aborderons celui du Unit Of Work.

La partie suivante sera dédiée à son implémentation.

Pré requis

  • Connaissance de l’ORM Entity Framework
  • Connaissance du pattern Repository
  • Connaissance de la modélisation objet
  • Installation des packages Nuget Microsoft.EntityFrameworkCore et Microsoft.EntityFrameworkCore.Relational

Approche conceptuelle

Rappel sur Entity Framework

Entity Framework est un ORM (Object Relationnal Mapping). C’est un outil permettant de créer une couche d’accès aux données (DAL pour Data Access Layer) liée à une base de données relationnelle. Il propose la création d’un schéma conceptuel composé d’entités qui permettent la manipulation d’une source de données, sans écrire une seule ligne de SQL, grâce à LinQ To Entities. Comparé à d’autres solutions de mapping objet-relationnel (ORM), Entity Framework assure l’indépendance du schéma conceptuel (entités ou objets) du schéma logique de la base de données, c’est-à-dire des tables. Ainsi, le code produit et le modèle conceptuel ne sont pas couplés à une base de données spécifique.

Rappel du pattern Repository

Ce design pattern répond à un besoin d’accès aux données stockées en base. Son objectif principal est d’isoler la couche d’accès aux données de la couche métier.
Il expose diverses méthodes s’appuyant sur le modèle CRUD (Create, Read, Update, Delete). Dans le contexte d’un projet Entity Framework, un Repository (ou dépôt) est souvent cantonné à la manipulation d’une entité spécifique. On retrouve donc un Repository par entités ayant besoin d’être gérées.

Qu’est ce que le pattern Unit Of Work ?

Unit Of Work est un design pattern qui répond à beaucoup de problèmes de développement et apporte les avantages suivants :

  • Il permet de garder en mémoire les modifications logiques de base de données dans un ensemble cohérent.
  • Il permet d’orchestrer les opérations de base sous forme de transactions pour pouvoir annuler les modifications en cas de problèmes.
  • Il permet d’isoler votre application des modifications de la couche de données et ainsi faciliter les tests et le partage des développements.
  • Il coordonne le travail des différents Repositories en ne créant qu’un seul contexte partagé.

Un peu de généricité dans ce monde complexe

Dans un soucis de performance, de cohérence, de maintenabilité et de rapidité des développements, le développeur, flemmard comme à son habitude, tend à s’orienter, au possible, vers la factorisation et la ré-utilisabilité de son code.

Dans l’implémentation suivante, il en sera question. Nous allons ainsi pouvoir créer des Repository implémentant un modèle de Repository générique.

N’oublions pas de partir sur les bonnes bases du modèle objet

Le modèle objet n’est pas des plus simples à appréhender. Il est plus facile de raisonner de manière linéaire et concrète, que de manière abstraite et avec des notions d’héritage ou de polymorphisme. Cela nécessite un certain recul et une capacité d’abstraction qui n’est pas totalement intuitive et plus difficilement accessible.

Dans l’implémentation qui va suivre, nous utiliserons les principes de l’héritage, de l’abstraction, du polymorphisme et les types génériques.

Nous respecterons aussi le principe SOLID.

Implémentation de la solution

Présentation du contexte

Dans cet article nous traiterons de séries, de saisons et d’épisodes. Une série contient une liste de saisons qui chacunes contiennes des épisodes.

Soit le modèle d’entités Entity Framework suivant :

public class Entity {} // Allow specifying class as Entity Framework entities. This class is empty

public class Serie: Entity { // So we extends the Entity class
    public Guid IdSerie { get; set; }
    public string Title { get; set; }
    public double Rating { get; set; }
    public IEnumerable<Season> Seasons { get; set; }
}

public class Season: Entity {
    public Guid IdSeason { get; set; }
    public int Number { get; set; }
    public Guid IdSerie { get; set; }
    public Serie Serie { get; set; }
}

public class Episode: Entity {
    public Guid IdEpisode { get; set; }
    public int Number { get; set; }
    public double Duration { get; set; }
    public Guid IdSeason { get; set; }
    public Season Season { get; set; }
}

La couche repository

Le GenericRepository

Ici, nous allons nous atteler à la création d’un modèle générique de Repository qui pourra être implémenté par tous nos Entity Repositories.

Le but ici est de créer un modèle simple et viable de Repository implémentant les fonctions de base du CRUD (Create, Read, Update, Delete) et pouvant convenir à tous types d’entités gérées par Entity Framework.

Pour commencer nous allons créer l’Interface de ce Repository Générique acceptant n’importe quel type d’entités afin de pouvoir l’implémenter plus tard.

// Dans cet article, les commentaires sont présents dans l'Interface mais ne le seront pas dans son implémentation
public interface IGenericRepository<TEntity> where TEntity : Entity
{
    #region CREATE
    /// <summary>
    /// Inserts a new entity.
    /// </summary>
    /// <param name="entity">The entity to insert.</param>
    void Add(TEntity entity);

    /// <summary>
    /// Inserts a range of entities.
    /// </summary>
    /// <param name="entities">The entities to insert.</param>
    void Add(IEnumerable<TEntity> entities);
    #endregion

    #region READ
    /// <summary>
    /// Finds an entity with the given primary key values.
    /// </summary>
    /// <param name="keyValues">The values of the primary key.</param>
    /// <returns>The found entity or null.</returns>
    TEntity GetById(params object[] keyValues);

    /// <summary>
    /// Gets the first or default entity based on a predicate, orderby and children inclusions.
    /// </summary>
    /// <param name="predicate">A function to test each element for a condition.</param>
    /// <param name="orderBy">A function to order elements.</param>
    /// <param name="include">Navigation properties separated by a comma.</param>
    /// <param name="disableTracking">A boolean to disable entities changing tracking.</param>
    /// <returns>The first element satisfying the condition.</returns>
    /// <remarks>This method default no-tracking query.</remarks>
    TEntity GetFirstOrDefault(
        Expression<Func<TEntity, bool>> predicate = null,
        Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>> orderBy = null,
        Func<IQueryable<TEntity>, IIncludableQueryable<TEntity, object>> include = null,
        bool disableTracking = true
    );

    /// <summary>
    /// Gets all entities.
    /// </summary>
    /// <returns>The all dataset.</returns>
    IQueryable<TEntity> GetAll();

    /// <summary>
    /// Gets the entities based on a predicate, orderby and children inclusions.
    /// </summary>
    /// <param name="predicate">A function to test each element for a condition.</param>
    /// <param name="orderBy">A function to order elements.</param>
    /// <param name="include">A function to include navigation properties</param>
    /// <param name="disableTracking">A boolean to disable entities changing tracking.</param>
    /// <returns>A list of elements satisfying the condition.</returns>
    /// <remarks>This method default no-tracking query.</remarks>
    IEnumerable<TEntity> GetMuliple(
        Expression<Func<TEntity, bool>> predicate = null,
        Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>> orderBy = null,
        Func<IQueryable<TEntity>, IIncludableQueryable<TEntity, object>> include = null,
        bool disableTracking = true
    );

    /// <summary>
    /// Uses raw SQL queries to fetch the specified entity data.
    /// </summary>
    /// <param name="sql">The raw SQL.</param>
    /// <param name="parameters">The parameters.</param>
    /// <returns>A list of elements satisfying the condition specified by raw SQL.</returns>
    IQueryable<TEntity> FromSql(string sql, params object[] parameters);
    #endregion

    #region UPDATE
    /// <summary>
    /// Updates the specified entity.
    /// </summary>
    /// <param name="entity">The entity.</param>
    void Update(TEntity entity);

    /// <summary>
    /// Updates the specified entities.
    /// </summary>
    /// <param name="entities">The entities.</param>
    void Update(IEnumerable<TEntity> entities);
    #endregion

    #region DELETE
    /// <summary>
    /// Deletes the entity by the specified primary key.
    /// </summary>
    /// <param name="id">The primary key value.</param>
    void Delete(object id);

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

    /// <summary>
    /// Deletes the specified entities.
    /// </summary>
    /// <param name="entities">The entities to delete.</param>
    void Delete(IEnumerable<TEntity> entities);
    #endregion

    #region OTHER
    /// <summary>
    /// Gets the count based on a predicate.
    /// </summary>
    /// <param name="predicate">A function to test each element for a condition.</param>
    /// <returns>The number of rows.</returns>
    int Count(Expression<Func<TEntity, bool>> predicate = null);

    /// <summary>
    /// Check if an element exists for a condition.
    /// </summary>
    /// <param name="predicate">A function to test each element for a condition.</param>
    /// <returns>A boolean</returns>
    bool Exists(Expression<Func<TEntity, bool>> predicate);
    #endregion
}

Nous voilà donc avec un contrat de Repository. Il n’est cependant pas fonctionnel en l’état, nous devons l’implémenter dans une classe mère utilisable en tant que telle pour des besoins simple, mais que les Repository enfants pourront étendre. Implémentons ici les méthodes CRUD et le contexte de base de donnée.

public class GenericRepository<TEntity> : IGenericRepository<TEntity> where TEntity : Entity
{
    protected readonly DbContext _dbContext;
    protected readonly DbSet<TEntity> _dbSet;

    /// <summary>
    /// Initializes a new instance of the GenericRepository<TEntity>.
    /// </summary>
    /// <param name="dbContext">The database context.</param>
    public GenericRepository(DbContext dbContext)
    {
        _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
        _dbSet = _dbContext.Set<TEntity>();
    }

    #region CREATE
    public virtual void Add(TEntity entity)
    {
        var entry = _dbSet.Add(entity);
    }

    public virtual void Add(IEnumerable<TEntity> entities) => _dbSet.AddRange(entities);
    #endregion

    #region READ
    public virtual TEntity GetById(params object[] keyValues) => _dbSet.Find(keyValues);

    public virtual TEntity GetFirstOrDefault(
        Expression<Func<TEntity, bool>> predicate = null,
        Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>> orderBy = null,
        Func<IQueryable<TEntity>, IIncludableQueryable<TEntity, object>> include = null,
        bool disableTracking = true
    )
    {
        IQueryable<TEntity> query = _dbSet;
        if (disableTracking)
        {
            query = query.AsNoTracking();
        }

        if (include != null)
        {
            query = include(query);
        }

        if (predicate != null)
        {
            query = query.Where(predicate);
        }

        if (orderBy != null)
        {
            return orderBy(query).FirstOrDefault();
        }
        else
        {
            return query.FirstOrDefault();
        }
    }

    public IQueryable<TEntity> GetAll()
    {
        return _dbSet;
    }

    public virtual IEnumerable<TEntity> GetMuliple(
        Expression<Func<TEntity, bool>> predicate = null,
        Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>> orderBy = null,
        Func<IQueryable<TEntity>, IIncludableQueryable<TEntity, object>> include = null,
        bool disableTracking = true
    )
    {
        IQueryable<TEntity> query = _dbSet;

        if (disableTracking)
        {
            query = query.AsNoTracking();
        }

        if (include != null)
        {
            query = include(query);
        }

        if (predicate != null)
        {
            query = query.Where(predicate);
        }

        if (orderBy != null)
        {
            return orderBy(query).ToList();
        }
        else
        {
            return query.ToList();
        }
    }

    public virtual IQueryable<TEntity> FromSql(
        string sql,
        params object[] parameters
    ) => _dbSet.FromSql(sql, parameters);
    #endregion

    #region UPDATE
    public virtual void Update(TEntity entity)
    {
        _dbSet.Update(entity);
    }

    public virtual void Update(IEnumerable<TEntity> entities) => _dbSet.UpdateRange(entities);
    #endregion

    #region DELETE
    public virtual void Delete(object id)
    {
        var entityToDelete = _dbSet.Find(id);

        if (entityToDelete != null)
        {
            _dbSet.Remove(entityToDelete);
        }
    }

    public virtual void Delete(TEntity entityToDelete)
    {
        if (_dbContext.Entry(entityToDelete).State == EntityState.Detached)
        {
            _dbSet.Attach(entityToDelete);
        }
        _dbSet.Remove(entityToDelete);
    }

    public virtual void Delete(IEnumerable<TEntity> entities) => _dbSet.RemoveRange(entities);
    #endregion

    #region OTHER
    public virtual int Count(Expression<Func<TEntity, bool>> predicate = null)
    {
        if (predicate == null)
        {
            return _dbSet.Count();
        }
        else
        {
            return _dbSet.Count(predicate);
        }
    }

    public virtual bool Exists(Expression<Func<TEntity, bool>> predicate)
    {
        return _dbSet.Any(predicate);
    }
    #endregion
}

Notre GenericRepository est maintenant utilisable pour des besoins très simples mais suffisant pour dans de nombreux cas d’utilisation. Nous pourrions d’ores et déjà l’utiliser de la façon suivante :

using (var context = new MyContext()) {
    var serieRepo = new GenericRepository<Serie>(context);
    
    // Get all the series rating equal 10/20 order by names
    var series = serieRepo.GetMultiple(
        predicat: (
            s => s.Rating == 10d
        ),
        /*
        * If you want also to retrieve each seasons and their episodes
        * inclusions: (
        *     source => source.Include(serie => serie.Seasons).ThenInclude(season => season.Episodes)
        * ),
        */
        orderBy: (
            s => s.OrderByDescending(s1 => s1.Rating)
       )
    );

    series.ForEach(var serie in series) {
        Console.Writeline($"Nom : {serie.Title} / Rating : {serie.Rating}");
    }
}

Notre Repository générique est près à répondre à quasiment tous les besoins. Nous allons quand même aller un peu plus loin et prévoir d’hériter de ce modèle générique dans des Repositores qui auraient des fonctions plus complexes que nos fonctions de CRUD listées ci-dessus. Aussi les méthodes du GenericRepository sont déclarées virtual pour pouvoir être implémentées et redéfinies et notre classe mère est héritable.

Dans l’exemple suivant nous allons écrire un CustomSerieRepository qui ajoutera une fonction implémentant un requête LINQ plus complexe comme un union entre deux tables ou la lecture d’une vue etc…

Commençons par écrire l’interface :

public interface ICustomSerieRepository : IGenericRepository<Serie>
{
    #region READ
    IEnumerable<Serie> ComplexQuery();
    #endregion
}

Puis son implémentation :

public class CustomSerieRepository : GenericRepository<Serie>, ICustomSerieRepository
{
    /// <summary>
    /// Initializes a new instance of the CustomSerieRepository.
    /// </summary>
    /// <param name="dbContext">The database context.</param>
    public CustomSerieRepository(DbContext dbContext)
        : base(dbContext)
    {
            
    }

    #region READ
    public IEnumerable<Serie> ComplexQuery()
    {
        // Simulate a complex query
        return new List<Serie>();
    }
    #endregion
}

Ce CustomSerieRepository pourrait s’utiliser de la façon suivante :

using (var context = new MyContext()) {
    var customSerieRepo = new CustomSerieRepository(context);
    
    // Get all the series rating equal 10/20 order by names
    var series = customSerieRepo .GetMultiple(
        predicat: (
            s => s.Rating == 10d
        ),
        /*
        * If you want also to retrieve each seasons and their episodes
        * inclusions: (
        *     source => source.Include(serie => serie.Seasons).ThenInclude(season => season.Episodes)
        * ),
        */
        orderBy: (
            s => s.OrderByDescending(s1 => s1.Rating)
       )
    );

    series.ForEach(var serie in series) {
        Console.Writeline($"Nom : {serie.Title} / Rating : {serie.Rating}");
    }

    // Retrieve series with complex query
    var seriesFromComplexQuery = customSerieRepo.ComplexQuery();
}

Voilà donc deux façons de manipuler des Series via les Repositories : de la manière générique ou par l’héritage.

Gardez à l’esprit que toute manipulation métier d’une ou plusieurs entités trouvera sa place dans le Business Layer, le Repository ne fait que l’interface entre la base de données et le modèle.

La couche Unit Of Work

Le Unit Of Work, pour rappel vient se placer entre la couche de service (Business Layer) et la couche de données (Data Access Layer).

Il a ici la responsabilité de du DBContext, des différents Repositories et de la cohérence transactionnelle des opérations.

Commençons encore une fois par l’Interface. Celle-ci décrit le comportement du Unit Of Work.
Il implémente l’Interface IDisposable pour pouvoir être supprimé de la mémoire au besoin ou soit dans un bloc d’instruction using. Cela nous permet d’imposer une durée de vie au contexte afin de ne pas maintenir de connexions ouverte, de contextes chargés en mémoire etc…

// Dans cet article, les commentaires sont présents dans l'Interface mais ne le seront pas dans son implémentation
public interface IUnitOfWork<TContext> : IDisposable where TContext : DbContext
{
    /// <summary>
    /// Gets the db context.
    /// </summary>
    /// <returns>The instance of type TContext.</returns>
    TContext DbContext { get; }

    /// <summary>
    /// Gets the specified repository for the TEntity.
    /// </summary>
    /// <typeparam name="TEntity">The type of the entity.</typeparam>
    /// <returns>An instance of type inherited from GenericRepository interface.</returns>
    IGenericRepository<TEntity> GetRepository<TEntity>() where TEntity : Entity;

    /// <summary>
    /// Executes the specified raw SQL command.
    /// </summary>
    /// <param name="sql">The raw SQL.</param>
    /// <param name="parameters">The parameters.</param>
    /// <returns>The number rows affected.</returns>
    int ExecuteSqlCommand(string sql, params object[] parameters);

    /// <summary>
    /// Uses raw SQL queries to fetch the specified TEntity data.
    /// </summary>
    /// <typeparam name="TEntity">The type of the entity.</typeparam>
    /// <param name="sql">The raw SQL.</param>
    /// <param name="parameters">The parameters.</param>
    /// <returns>An IQueryable for TEntity that contains elements that satisfy the condition specified by raw SQL.</returns>
    IQueryable<TEntity> FromSql<TEntity>(string sql, params object[] parameters) where TEntity : Entity;

    /// <summary>
    /// Commit all changes made in this context to the database.
    /// </summary>
    /// <returns>The number of state entries written to the database.</returns>
    int Save();
}

Et voici son implémentation :

public class UnitOfWork<TContext> : IUnitOfWork<TContext> where TContext : DbContext
{
    private readonly TContext _context;
    private bool disposed = false;
    private Dictionary<Type, object> _repositories;
    private ICustomSerieRepository _customSerieRepo;

    /// <summary>
    /// Initializes a new instance of the UnitOfWork<TContext>.
    /// </summary>
    /// <param name="context">The context.</param>
    public UnitOfWork(TContext context)
    {
        _context = context ?? throw new ArgumentNullException(nameof(context));
    }

    public TContext DbContext => _context;

    public IGenericRepository<TEntity> GetRepository<TEntity>() where TEntity : Entity
    {
        if (_repositories == null)
        {
            _repositories = new Dictionary<Type, object>();
        }

        var type = typeof(TEntity);
        if (!_repositories.ContainsKey(type))
        {
            _repositories[type] = new GenericRepository<TEntity>(_context);
        }

        return (IGenericRepository<TEntity>)_repositories[type];
    }

    public ICustomSerieRepository CustomSerieRepository {
        get
        {
            if (_customSerieRepo == null) {
                _customSerieRepo = new CustomSerieRepository(_context);
            }

            return _customSerieRepo;
        }
    }

    public int ExecuteSqlCommand(
        string sql,
        params object[] parameters
    ) => _context.Database.ExecuteSqlCommand(sql, parameters);

    public IQueryable<TEntity> FromSql<TEntity>(
        string sql,
        params object[] parameters
    ) where TEntity : Entity => _context.Set<TEntity>().FromSql(sql, parameters);

    public int Save()
    {
        return _context.SaveChanges();
    }

    protected virtual void Dispose(bool disposing)
    {
        if (!this.disposed)
        {
            if (disposing)
            {
                _repositories.Clear();
                _context.Dispose();
            }
        }
        this.disposed = true;
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }
}

La class UnitOfWork contient le contexte de base de données souvent lié à la vie de la session utilisateur et retourne des IGenericRepository<TEntity> et custom Repositories au besoin. Elle s’occupe du commit de la transaction des opérations sur la base de données et rend le tout disposable.

Nous pouvons maintenant l’utiliser dans un cas un peu plus concret en profitant de l’injection de dépendance proposée par Microsoft .Net Core.

Prenons l’exemple d’un projet APP.NET Core.

Le fichier Statup.cs devra être configuré comme suit :

...
public void ConfigureServices(IServiceCollection services)
{
    // Your code

    services.AddScoped<IUnitOfWork<MyContext>, UnitOfWork<MyContext>>();

    services.AddScoped<ISerieService>(
        provider => new SerieService
        (
            provider.GetRequiredService<ILogger<ICarteService>>(),
            provider.GetService<IUnitOfWork>()
        )
    );
}
...

La classe de service MyService pourrait être la suivante :

public class MyService : IMyService {
    private ILogger _logger;
    private IUnitOfWork _uow;

    public MyService(ILogger logger, IUnitOfWork uow) {
        _logger = logger;        
        _uow = uow;
    }

    public void MyFunction() {
        // Get series
        var series = _uow.CustomSerieRepository.ComplexQuery();

        // Update each series ratings
        series.ForEach(var serie in series) {
            serie.Rating = serie.Rating + 1;
        }

        // Insert a new episode
        _uow.GetRepository<Episode>().Insert(new Episode { ... });

        // Delete the first season of the first serie
        _uow.GetRepository<Season>().Delete(series[0].Season[0].IdSeason);

        try {
            // Commit the operations transaction 
            _uow.Save();
        }
        catch (Exception ex) {
            _logger.Error(ex, "Reason");
            // If needed, dispose the Unit Of Work
            // _uow.Dispose();
        }
    }
}

Ou :

using (var uow = UnitOfWork(new MyContext())) {
    // Get series
    var series = _uow.CustomSerieRepository.ComplexQuery();

    // Update each serie ratings
    series.ForEach(var serie in series) {
        serie.Rating = serie.Rating + 1;
    }

    // Insert new episode
    _uow.GetRepository<Episode>().Insert(new Episode { ... });

    // Delete the first season of the first serie
    _uow.GetRepository<Season>().Delete(series[0].Season[0].IdSeason);

    // Commit transaction
    _uow.Save();
}

Conclusion

Vous avez maintenant les billes pour implémenter à votre façon un modèle générique de Repositories et du Unit Of Work. Ce code a été proposé à titre d’exemple. Le Unit Of Work est là pour répondre à des besoins spécifiques qui ne seront pas forcément les vôtres. Le Unit Of Work propose une programmation transactionnelle, mais d’autres modèles existent. Utilisez le donc en connaissance de causes et uniquement s’il apporte une plus-value à vos projets.

Dans le développement, la généricité est une bonne chose mais peut conduire à une certaine complexité de code et pourrait ne pas répondre à tous les besoins de votre application.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *