patron-repository

Repository Pattern: abstract data access

  • 5 min

A Repository is an abstraction that provides operations for accessing domain entities without exposing persistence details to the consumer. It’s like a library counter: you ask for a book without needing to know its physical location.

Imagine you go to a library to get a book. You don’t go down to the basement, open the packing boxes, and search for the book yourself. What you do is go to the counter and ask the librarian: “Hello, do you have ‘Don Quixote’?” The librarian goes, looks for it (knows if it’s on shelf 5 or in the warehouse), and brings it to you. You don’t care where they got it from.

The Repository pattern acts like that librarian.

It’s an abstraction layer between the Business Logic of our application and the Data Access Layer. Its goal is to make database access feel like working with a simple collection of objects in memory.

The Problem: Spaghetti SQL

Without this pattern, it’s common to see code where Controllers or Services know too much about the database.

// ❌ Tightly coupled and hard-to-test code
public class UsuarioService
{
    public void RegistrarUsuario(Usuario user)
    {
        // Business logic mixed with data access
        if (user.Edad < 18) throw new Exception("Underage");

        // Tightly coupled to SQL Server and SqlClient library!
        using (var conn = new SqlConnection("Server=..."))
        {
            conn.Open();
            var cmd = new SqlCommand("INSERT INTO Users VALUES (@name)...", conn);
            cmd.ExecuteNonQuery();
        }
    }
}
Copied!

Problems:

  1. Duplication: If you need to find users in multiple places, you repeat the SELECT * FROM... query.
  2. Zero Testability: To test RegistrarUsuario, you need a real, running database.
  3. Rigidity: If you want to switch from SQL Server to MongoDB or an XML file, you have to rewrite the entire application.

The Solution: The Repository

The Repository pattern proposes creating an Interface that defines data access operations (CRUD: Create, Read, Update, Delete) using domain objects, not tables or SQL.

The business logic will only communicate with this interface.

Implementation in C#

Let’s decouple our user service.

The Entity (Model)

A simple POCO (Plain Old CLR Object).

public class Usuario
{
    public int Id { get; set; }
    public string Nombre { get; set; }
    public string Email { get; set; }
}
Copied!

The Repository Interface

Here we define the contract. Notice there is no trace of SQL.

public interface IUsuarioRepositorio
{
    Usuario GetPorId(int id);
    IEnumerable<Usuario> GetTodos();
    void Agregar(Usuario usuario);
    void Eliminar(int id);
}
Copied!

The Concrete Implementation (SQL)

This is where we get our hands dirty with the database. In a real project, we would use an ORM like Entity Framework Core, but for the example, we’ll simulate a list or direct access.

public class UsuarioRepositorioSQL : IUsuarioRepositorio
{
    // Simulate the DB
    private static List<Usuario> _fakeDb = new List<Usuario>(); 

    public Usuario GetPorId(int id)
    {
        // Here it would be: _context.Users.FirstOrDefault(u => u.Id == id);
        return _fakeDb.FirstOrDefault(u => u.Id == id);
    }

    public IEnumerable<Usuario> GetTodos()
    {
        return _fakeDb;
    }

    public void Agregar(Usuario usuario)
    {
        Console.WriteLine($"[SQL] Inserting user {usuario.Nombre} into Users table...");
        _fakeDb.Add(usuario);
    }

    public void Eliminar(int id)
    {
        var user = GetPorId(id);
        if (user != null) _fakeDb.Remove(user);
    }
}
Copied!

The Consumer (Dependency Injection)

Now our service is clean. It receives the repository via injection (DI).

public class UsuarioService
{
    private readonly IUsuarioRepositorio _repo;

    // Inject the interface, not the concrete class
    public UsuarioService(IUsuarioRepositorio repo)
    {
        _repo = repo;
    }

    public void RegistrarUsuario(string nombre, string email)
    {
        // Business validations
        if (string.IsNullOrEmpty(nombre)) 
            throw new ArgumentException("Name is required");

        var nuevoUsuario = new Usuario { Id = new Random().Next(100), Nombre = nombre, Email = email };

        // Delegate saving to the repository
        _repo.Agregar(nuevoUsuario);
    }
    
    public void MostrarTodos()
    {
        var usuarios = _repo.GetTodos();
        foreach(var u in usuarios) 
            Console.WriteLine($"- {u.Nombre} ({u.Email})");
    }
}
Copied!

Putting it all together

class Program
{
    static void Main(string[] args)
    {
        // 1. Configure dependencies (usually done by the DI container)
        IUsuarioRepositorio repo = new UsuarioRepositorioSQL();
        var servicio = new UsuarioService(repo);

        // 2. Use the service
        servicio.RegistrarUsuario("Luis", "[email protected]");
        servicio.RegistrarUsuario("Ana", "[email protected]");

        Console.WriteLine("\nUser list:");
        servicio.MostrarTodos();
    }
}
Copied!

Generic Repository

One problem with this pattern is that you end up creating many repetitive classes (ProductoRepositorio, PedidoRepositorio, FacturaRepositorio) that do almost the same thing.

In C#, we can use Generics to create a Base Repository.

public interface IRepositorio<T> where T : class
{
    T GetById(int id);
    void Add(T entity);
    // ...
}

public class RepositorioGenerico<T> : IRepositorio<T> where T : class
{
    // Implementation using Entity Framework DbContext
    // public T GetById(int id) => _dbSet.Find(id);
}
Copied!

This way we can reduce repetitive code, but a generic CRUD repository can limit useful queries or duplicate the ORM’s API. In EF Core, DbSet<T> already offers much of this behavior; add your own layer only when it expresses domain operations or creates a real boundary.

Key Advantages

  1. Testability: You can use a test repository to isolate business rules. However, you still need integration tests because a list does not reproduce queries, constraints, or transactions of a real database.
  2. Decoupling: You can change the database engine without touching the business logic.
  3. Centralization: Complex queries (e.g., “Active users with more than 5 orders”) are in one place, not scattered across 20 controllers.

Repository is a common choice for separating data and logic, but it’s not mandatory on top of any ORM.