Repository Pattern

Introduktion

Repository Pattern är ett arkitekturmönster som skapar ett abstraktionslager mellan din affärslogik och dataåtkomstlagret. Istället för att sprida SQL-frågor eller ORM-anrop överallt i koden, samlas all dataåtkomst för en entitet på ett ställe — i ett repository.


Problemet utan Repository

// Utan repository — databaslogik läcker in i affärslogiken
public class OrderService {
    private final EntityManager em;

    public void confirmOrder(String orderId) {
        // SQL direkt i affärslogiken — svårt att testa och byta ut
        Order order = em.createQuery(
            "SELECT o FROM Order o WHERE o.id = :id", Order.class)
            .setParameter("id", orderId)
            .getSingleResult();

        order.confirm();
        em.merge(order);

        // Tänk om vi byter till MongoDB? Hela denna klass måste skrivas om.
    }
}

Problemen:

  • Affärslogik och databaslogik är sammanvävda
  • Svårt att enhetstesta utan en riktig databas
  • Byta databas kräver ändringar i hela kodbasen

Lösningen: Repository Interface

// Med repository — affärslogiken bryr sig inte om databasen

// 1. Definiera kontraktet — ett interface i domänlagret
public interface OrderRepository {
    void       save(Order order);
    Optional<Order> findById(String id);
    List<Order>     findByCustomerId(String customerId);
    List<Order>     findPendingOrders();
    void       delete(String id);
}

// 2. Affärslogiken beror bara på interfacet
public class OrderService {
    private final OrderRepository orderRepository; // Bara ett interface!

    public void confirmOrder(String orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));

        order.confirm();                // Affärsregel på entiteten
        orderRepository.save(order);   // Sparas via interfacet
    }
}

Exempel — Fullständig Implementation (Java / Spring)

Domänentitet

public class Order {
    private String      id;
    private String      customerId;
    private List<OrderItem> items;
    private OrderStatus status;
    private Instant     createdAt;

    public Order(String customerId, List<OrderItem> items) {
        this.id         = UUID.randomUUID().toString();
        this.customerId = customerId;
        this.items      = new ArrayList<>(items);
        this.status     = OrderStatus.PENDING;
        this.createdAt  = Instant.now();
    }

    public void confirm() {
        if (status != OrderStatus.PENDING)
            throw new IllegalStateException("Kan bara bekräfta en väntande order.");
        this.status = OrderStatus.CONFIRMED;
    }

    public Money calculateTotal() {
        return items.stream()
            .map(OrderItem::getSubtotal)
            .reduce(Money.ZERO, Money::add);
    }

    // Getters...
}

Repository Interface

// I domänlagret — inga import av JPA, SQL eller MongoDB
public interface OrderRepository {
    void            save(Order order);
    Optional<Order> findById(String id);
    List<Order>     findByCustomerId(String customerId);
    List<Order>     findByStatus(OrderStatus status);
    long            countByCustomerId(String customerId);
    void            deleteById(String id);
}

JPA-implementation (SQL)

@Repository
public class JpaOrderRepository implements OrderRepository {

    private final JpaOrderEntityRepository jpaRepo; // Spring Data JPA

    public JpaOrderRepository(JpaOrderEntityRepository jpaRepo) {
        this.jpaRepo = jpaRepo;
    }

    @Override
    public void save(Order order) {
        jpaRepo.save(OrderEntity.fromDomain(order));
    }

    @Override
    public Optional<Order> findById(String id) {
        return jpaRepo.findById(id)
            .map(OrderEntity::toDomain);
    }

    @Override
    public List<Order> findByCustomerId(String customerId) {
        return jpaRepo.findByCustomerId(customerId)
            .stream()
            .map(OrderEntity::toDomain)
            .collect(Collectors.toList());
    }

    @Override
    public List<Order> findByStatus(OrderStatus status) {
        return jpaRepo.findByStatus(status.name())
            .stream()
            .map(OrderEntity::toDomain)
            .collect(Collectors.toList());
    }

    @Override
    public long countByCustomerId(String customerId) {
        return jpaRepo.countByCustomerId(customerId);
    }

    @Override
    public void deleteById(String id) {
        jpaRepo.deleteById(id);
    }
}

In-Memory-implementation (för tester)

// Ingen databas behövs för att köra enhetstester!
public class InMemoryOrderRepository implements OrderRepository {

    private final Map<String, Order> store = new HashMap<>();

    @Override
    public void save(Order order) {
        store.put(order.getId(), order);
    }

    @Override
    public Optional<Order> findById(String id) {
        return Optional.ofNullable(store.get(id));
    }

    @Override
    public List<Order> findByCustomerId(String customerId) {
        return store.values().stream()
            .filter(o -> o.getCustomerId().equals(customerId))
            .collect(Collectors.toList());
    }

    @Override
    public List<Order> findByStatus(OrderStatus status) {
        return store.values().stream()
            .filter(o -> o.getStatus() == status)
            .collect(Collectors.toList());
    }

    @Override
    public long countByCustomerId(String customerId) {
        return store.values().stream()
            .filter(o -> o.getCustomerId().equals(customerId))
            .count();
    }

    @Override
    public void deleteById(String id) {
        store.remove(id);
    }
}

Enhetstest utan databas

class OrderServiceTest {

    private OrderService       orderService;
    private OrderRepository    orderRepository;

    @BeforeEach
    void setUp() {
        // Ingen databas — vi använder in-memory-implementationen
        orderRepository = new InMemoryOrderRepository();
        orderService    = new OrderService(orderRepository);
    }

    @Test
    void confirmOrder_shouldUpdateStatus() {
        // Arrange
        Order order = new Order("cust-1", List.of(new OrderItem("prod-1", 2, Money.of(100))));
        orderRepository.save(order);

        // Act
        orderService.confirmOrder(order.getId());

        // Assert
        Order found = orderRepository.findById(order.getId()).orElseThrow();
        assertEquals(OrderStatus.CONFIRMED, found.getStatus());
    }

    @Test
    void confirmOrder_shouldThrow_whenOrderNotFound() {
        assertThrows(OrderNotFoundException.class,
            () -> orderService.confirmOrder("icke-existerande-id"));
    }
}

Repository vs. DAO

RepositoryDAO (Data Access Object)
AbstraktionsnivåDomänobjekt (affärslogik)Databastabeller
ReturnerarDomänentiteterDatastrukturer / DTOs
Passar medDDD, Clean ArchitectureEnklare applikationer
FrågetypAffärsregelbaseratTabellbaserat

Fördelar

  • Testbarhet — Byt ut mot In-Memory-implementation i tester.
  • Utbytbarhet — Byt SQL mot MongoDB utan att ändra affärslogiken.
  • Single Responsibility — All dataåtkomst för en entitet på ett ställe.
  • LäsbarhetorderRepository.findPendingOrders() är tydligare än en SQL-fråga.

Nackdelar

  • Mer kod — Interface + implementation + mapper = fler filer.
  • N+1-problem — Utan omsorg kan repositories generera ineffektiva databasanrop.
  • Kan vara överkurs — För enkla CRUD-appar utan komplex domänlogik.

Av Victor Hernandez från Bytebase.se