Ir al contenido
wannacode

Repository

En resumen: Repository esconde la base de datos detrás de una interfaz con métodos como find y save, de modo que las reglas de negocio no saben nada de SQL y el almacenamiento puede cambiar sin que el servicio se entere.

Problema

Consultas repartidas entre la página y la caja

La tienda da un 10 % de descuento a un cliente con tres pedidos o más. La página del cliente muestra si hay descuento, y la caja lo aplica al pagar. Cada una escribe su propia consulta a la base de datos y su propia regla:

// La página del cliente
static void showDiscount(Connection conn, String customer) throws SQLException {
try (PreparedStatement st = conn.prepareStatement("SELECT COUNT(*) FROM orders WHERE customer = ?")) {
st.setString(1, customer);
try (ResultSet rs = st.executeQuery()) {
rs.next();
int count = rs.getInt(1);
String text = count > 3 ? "10 % de descuento" : "sin descuento";
System.out.println(customer + ": " + text);
}
}
}
// La caja
static double checkout(Connection conn, String customer, double total) throws SQLException {
try (PreparedStatement st = conn.prepareStatement("SELECT id FROM orders WHERE customer = ?")) {
st.setString(1, customer);
int count = 0;
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
count++;
}
}
return count >= 3 ? total * 0.9 : total;
}
}
try (Connection conn = DriverManager.getConnection("jdbc:sqlite::memory:")) {
setup(conn);
showDiscount(conn, "María");
System.out.printf("María paga %.2f €%n", checkout(conn, "María", 50.00));
}
Salida
María: sin descuento
María paga 45.00 €

María tiene tres pedidos. Su página le dice «sin descuento», porque allí la regla es count > 3. La caja le da el descuento, porque allí es count >= 3. Dos sitios, dos versiones de una regla, y nadie lo nota hasta que un cliente se queja.

Hay un segundo problema. La regla está pegada a la consulta. Para comprobar si >= 3 es correcto, necesitas una base de datos con una tabla y filas. La tabla orders y sus filas las llena la función setup, como en las lecciones sobre la base de datos en el programa. Aquí está oculta para que no estorbe.

La idea

Repository: los datos detrás de una interfaz

Las consultas van a un solo lugar: un repositorio. Tiene unos pocos métodos con nombres en el lenguaje de la tienda, no de la base de datos: findById, findByCustomer, save. Quien necesita pedidos los llama y no sabe si detrás hay SQL, un archivo o un diccionario en memoria.

Este es el patrón Repository. Alrededor, el código se ordena en capas, y cada capa habla solo con la capa de debajo:

  • el controlador (la página, la caja) recibe la petición y muestra la respuesta;
  • el servicio tiene la regla: «tres pedidos o más dan un 10 %»;
  • el repositorio da y guarda datos. No hay reglas en él;
  • los datos: memoria, SQLite u otra base de datos.

El servicio recibe el repositorio desde fuera, en su constructor. Es la inyección de dependencias (Dependency Injection) de la lección Singleton e inyección de dependencias. Por eso debajo puede ir cualquier repositorio con los mismos métodos.

Míralo

El contrato y el repositorio en memoria

El pedido y el contrato del repositorio: tres métodos, y ni una línea de SQL.

record Order(int id, String customer, double total) {}
interface OrderRepository {
Order findById(int id);
List<Order> findByCustomer(String customer);
void save(Order order);
}

El primer repositorio guarda los pedidos en memoria, en un diccionario del número al pedido. save con el mismo número reemplaza el pedido viejo.

class InMemoryOrderRepository implements OrderRepository {
private final Map<Integer, Order> orders = new LinkedHashMap<>();
public Order findById(int id) {
return orders.get(id);
}
public List<Order> findByCustomer(String customer) {
List<Order> result = new ArrayList<>();
for (Order order : orders.values()) {
if (order.customer().equals(customer)) {
result.add(order);
}
}
return result;
}
public void save(Order order) {
orders.put(order.id(), order);
}
}

Una función llena cualquier repositorio con los pedidos de la tienda. Solo conoce el contrato, así que funciona con la memoria y con la base de datos:

static void fill(OrderRepository orders) {
orders.save(new Order(1, "María", 80.00));
orders.save(new Order(2, "Mateo", 25.50));
orders.save(new Order(3, "María", 40.00));
orders.save(new Order(4, "María", 12.90));
}

Míralo

El servicio no sabe de dónde vienen los datos

La regla está en el servicio, y el controlador solo muestra la respuesta. Ninguno de los dos escribe SQL:

class LoyaltyService {
private final OrderRepository orders;
LoyaltyService(OrderRepository orders) {
this.orders = orders;
}
int discountFor(String customer) {
return orders.findByCustomer(customer).size() >= 3 ? 10 : 0;
}
}
class OrderController {
private final LoyaltyService loyalty;
OrderController(LoyaltyService loyalty) {
this.loyalty = loyalty;
}
void show(String customer) {
int discount = loyalty.discountFor(customer);
String text = discount > 0 ? "" + discount + " % de descuento" : "sin descuento";
System.out.println(customer + ": " + text);
}
}

El mismo servicio y el mismo controlador, primero sobre la memoria, luego sobre SQLite:

OrderRepository memory = new InMemoryOrderRepository();
fill(memory);
new OrderController(new LoyaltyService(memory)).show("María");
try (Connection conn = DriverManager.getConnection("jdbc:sqlite::memory:")) {
OrderRepository database = new SqliteOrderRepository(conn);
fill(database);
new OrderController(new LoyaltyService(database)).show("María");
}
Salida
María: 10 % de descuento
María: 10 % de descuento

class LoyaltyService {
private final OrderRepository orders;
LoyaltyService(OrderRepository orders) {
this.orders = orders;
}
int discountFor(String customer) {
return orders.findByCustomer(customer).size() >= 3 ? 10 : 0;
}
}
class OrderController {
private final LoyaltyService loyalty;
OrderController(LoyaltyService loyalty) {
this.loyalty = loyalty;
}
void show(String customer) {
int discount = loyalty.discountFor(customer);
String text = discount > 0 ? "" + discount + " % de descuento" : "sin descuento";
System.out.println(customer + ": " + text);
}
}
OrderRepository memory = new InMemoryOrderRepository();
fill(memory);
new OrderController(new LoyaltyService(memory)).show("María");
try (Connection conn = DriverManager.getConnection("jdbc:sqlite::memory:")) {
OrderRepository database = new SqliteOrderRepository(conn);
fill(database);
new OrderController(new LoyaltyService(database)).show("María");
}
Paso a paso, en palabras
  1. Un repositorio en memoria, lleno de pedidos. El servicio lo recibe sin saber qué es.
  2. El controlador recibe la petición para María. No le pregunta a la base de datos: llama al servicio.
  3. El servicio necesita los pedidos del cliente y se los pide al repositorio.
  4. findByCustomer sobre OrderRepository. Responde el repositorio en memoria: recorre su diccionario.
  5. Tres pedidos de María suben. La regla está aquí y solo aquí: 3 ≥ 3, así que 10 %.
  6. El controlador solo muestra la respuesta: «María: 10 % de descuento».
  7. El cambio: SQLite ocupa el lugar de la memoria. Es el mismo OrderRepository, así que el servicio y el controlador no se tocan.
  8. La misma llamada. Solo es distinto el objeto que hay debajo del servicio.
  9. findByCustomer ahora envía SELECT … WHERE customer = ? a la tabla orders.
  10. Otra vez tres filas, otra vez 10 %. El servicio no se enteró de que la fuente es otra.
  11. La misma línea, esta vez desde una base de datos.
  12. Controlador → servicio → repositorio → datos. Solo cambió la capa de más abajo, y la regla se quedó en un solo lugar.

Solo cambió la capa de más abajo. El servicio y el controlador son las mismas clases, sin una sola línea cambiada, y María ahora recibe el descuento tanto en la página como en la caja, porque hay una sola regla.

Míralo

La base de datos real detrás de la misma interfaz

Aquí está el repositorio en SQLite. Cada método es una consulta con parámetros (?), nunca texto pegado dentro del SQL. Por qué esto importa está en la lección Inyección SQL y parámetros.

class SqliteOrderRepository implements OrderRepository {
private final Connection conn;
SqliteOrderRepository(Connection conn) throws SQLException {
this.conn = conn;
try (Statement st = conn.createStatement()) {
st.executeUpdate("CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, customer TEXT NOT NULL, total REAL NOT NULL)");
}
}
public Order findById(int id) {
try (PreparedStatement st = conn.prepareStatement("SELECT id, customer, total FROM orders WHERE id = ?")) {
st.setInt(1, id);
try (ResultSet rs = st.executeQuery()) {
return rs.next() ? read(rs) : null;
}
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
public List<Order> findByCustomer(String customer) {
try (PreparedStatement st = conn.prepareStatement("SELECT id, customer, total FROM orders WHERE customer = ? ORDER BY id")) {
st.setString(1, customer);
List<Order> result = new ArrayList<>();
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
result.add(read(rs));
}
}
return result;
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
public void save(Order order) {
try (PreparedStatement st = conn.prepareStatement(
"INSERT INTO orders (id, customer, total) VALUES (?, ?, ?) ON CONFLICT (id) DO UPDATE SET customer = excluded.customer, total = excluded.total")) {
st.setInt(1, order.id());
st.setString(2, order.customer());
st.setDouble(3, order.total());
st.executeUpdate();
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
private static Order read(ResultSet rs) throws SQLException {
return new Order(rs.getInt("id"), rs.getString("customer"), rs.getDouble("total"));
}
}
Los métodos de la interfaz no declaran SQLException, así que el error de la base de datos se envuelve en una IllegalStateException. Así el servicio no depende de java.sql, y el error igualmente llega a quien puede mostrarlo.

Aquí vive el SQL de los pedidos. Aquí puedes arreglar una consulta lenta, añadir un índice o cambiar SQLite por PostgreSQL, y el servicio no se entera. Si el repositorio se convierte en un ORM, cuidado con una consulta por fila: la lección ORM y el problema N+1 muestra cómo se ve.

Míralo

La regla sin base de datos

Como el servicio acepta cualquier repositorio, la regla se comprueba con el repositorio en memoria: sin archivo, sin servidor, en milisegundos.

InMemoryOrderRepository orders = new InMemoryOrderRepository();
LoyaltyService loyalty = new LoyaltyService(orders);
orders.save(new Order(1, "María", 80.00));
orders.save(new Order(2, "María", 40.00));
System.out.println("2 pedidos: " + loyalty.discountFor("María") + "%");
orders.save(new Order(3, "María", 12.90));
System.out.println("3 pedidos: " + loyalty.discountFor("María") + "%");
Salida
2 pedidos: 0%
3 pedidos: 10%

Dos pedidos no bastan, el tercero da un 10 %. Es justo el límite en el que la página y la caja no estaban de acuerdo. Así se ve también una prueba de la regla: llenas el repositorio en memoria y compruebas lo que dice el servicio.

Pruébalo

¿Qué saldrá?

El pedido número 1 se guarda dos veces, con un total distinto. ¿Qué imprime el programa?

OrderRepository orders = new InMemoryOrderRepository();
orders.save(new Order(1, "María", 80.00));
orders.save(new Order(1, "María", 50.00));
System.out.println(orders.findByCustomer("María").size());
System.out.printf("%.2f €%n", orders.findById(1).total());
Respuesta

Pruébalo

Encuentra el error

Alguien decide que la búsqueda por cliente es lenta y escribe un repositorio que guarda los pedidos ya por cliente:

class FastOrderRepository implements OrderRepository {
private final Map<String, List<Order>> byCustomer = new HashMap<>();
public Order findById(int id) {
for (List<Order> orders : byCustomer.values()) {
for (Order order : orders) {
if (order.id() == id) {
return order;
}
}
}
return null;
}
public List<Order> findByCustomer(String customer) {
return byCustomer.getOrDefault(customer, new ArrayList<>());
}
public void save(Order order) {
byCustomer.values().forEach(orders -> orders.removeIf(o -> o.id() == order.id()));
byCustomer.computeIfAbsent(order.customer(), c -> new ArrayList<>()).add(order);
}
}

En el servicio aparece un método nuevo: «¿habrá descuento si compra también este?». Luego la página pregunta cuántos pedidos tiene Mateo:

class LoyaltyService {
private final OrderRepository orders;
LoyaltyService(OrderRepository orders) {
this.orders = orders;
}
int discountFor(String customer) {
return orders.findByCustomer(customer).size() >= 3 ? 10 : 0;
}
int discountIfBuys(String customer, Order cart) {
List<Order> orders = this.orders.findByCustomer(customer);
orders.add(cart);
return orders.size() >= 3 ? 10 : 0;
}
}
OrderRepository orders = new FastOrderRepository();
fill(orders);
LoyaltyService loyalty = new LoyaltyService(orders);
System.out.println("Si Mateo compra una más: " + loyalty.discountIfBuys("Mateo", new Order(5, "Mateo", 30.00)) + "%");
System.out.println("Pedidos de Mateo: " + orders.findByCustomer("Mateo").size());
Salida
Si Mateo compra una más: 0%
Pedidos de Mateo: 2

Mateo tiene un pedido, y el repositorio dice dos. Nadie llamó a save. ¿En qué línea del repositorio está el error?

Elige la línea con el error.

La idea

Cuándo no lo necesitas

Repository añade una interfaz y al menos una clase. No compensa cuando:

  • el programa es un script pequeño con una consulta. Las capas serán más largas que el trabajo mismo;
  • usas un ORM que ya da un repositorio (Spring Data). Tu propia interfaz que solo le reenvía línea por línea es un archivo más sin nada nuevo;
  • el repositorio gana un método por cada página: findByCustomerAndStatusOrderByTotal y veinte más como ese. Entonces las consultas vuelven a estar repartidas, solo que con nombres más largos.

Compensa cuando las reglas deben comprobarse sin base de datos, cuando la fuente puede cambiar (una base de datos, un archivo, otro servicio) o cuando las mismas consultas se usan desde muchos sitios.

Ejercicio

Borrar del repositorio

Resuelve la tarea en tu editor.

Borrar. Añade delete(id) al contrato y a los dos repositorios. En SQLite es DELETE FROM orders WHERE id = ?. Muestra el descuento de María, borra el pedido 4 y muéstralo otra vez, una vez con la memoria y otra con la base de datos.

Salida
María: 10 % de descuento
María: sin descuento
María: 10 % de descuento
María: sin descuento

Más tareas

  • Una regla por total. La tienda cambia la regla: 10 % a partir de 150 € gastados. ¿Qué clase tocas? Compruébalo con el repositorio en memoria, sin base de datos.
  • Un tercer repositorio. Escribe un repositorio que guarde los pedidos en un archivo, uno por línea. El servicio y el controlador deben quedar sin cambios.
  • Una copia al buscar. Arregla el repositorio de «Encuentra el error» y añade una comprobación: tras discountIfBuys Mateo sigue teniendo 1 pedido.

Test

Ponte a prueba

1. ¿Dónde vive la regla «3 pedidos o más dan un 10 % de descuento»?
2. ¿Por qué la regla se puede comprobar sin una base de datos?
3. El repositorio devuelve la lista que guarda por dentro, y quien llama le añade algo. ¿Qué pasa?
4. El proyecto usa un ORM que ya da un repositorio para cada tabla. ¿Vale la pena poner encima una interfaz propia con los mismos métodos?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·