Enterprise Architecture Patterns Tutorial 0/65 lessons ~6 min read Lesson 18

    Repositories

    Repositories persist and rehydrate aggregates — collection-like interface hiding persistence technology from domain layer.

    Course progress0%
    Focus
    18 guided sections
    Practice signal
    Examples included
    Career prep
    Interview Q&A included

    Introduction

    Repositories persist and rehydrate aggregates — collection-like interface hiding persistence technology from domain layer. Uber trip aggregates load and save through TripRepository without domain code knowing about Cassandra, indexes, or cache layers.

    Real production story

    Uber's early trip service leaked SQL and Cassandra CQL into application services — domain rules interleaved with query strings, making tests require database containers and slowing aggregate refactors. A trip pricing change required editing twelve query sites; one missed site caused wrong fare on pooled rides in Brazil. Repository introduction defined TripRepository with findById, save, and optimistic version check; infrastructure module implemented mapping; domain tests used in-memory fake. Pricing change touched one aggregate and one repository mapper — Brazil fare bug class eliminated; domain test runtime dropped from 90s to 200ms.

    Business problem

    Uber trip lifecycle spans request, match, ride, payment — complex aggregate graphs persisted in polyglot stores. Persistence leakage couples domain to ops changes and blocks fast testing.

    • Change cost: Schema migration requires hunting SQL across services — error-prone.
    • Test velocity: Domain tests without repos require heavy integration environments.
    • Team boundaries: Infra team owns storage tuning; domain team owns rules — repository is the seam.

    Architecture overview

    Repository contract: get by ID returns aggregate or null; save persists full aggregate consistency boundary; delete rare — prefer soft lifecycle on aggregate. Query methods minimal — complex reads go to read models/CQRS.

    • Per aggregate root: TripRepository not TableRepository.
    • Mapping: Repository maps TripJpaEntity ↔ Trip domain — both directions.
    • Queries: Avoid rich query APIs on repo — Specification or read side handles reports.
    • Transaction: Repository participates in unit of work — one aggregate per transaction.

    Architecture motivation

    Repository pattern isolates persistence as infrastructure concern — domain depends on interface; adapter implements with ORM, Cassandra, or event store.

    • Force: Polyglot persistence (hot trip state in Cassandra, audit in S3).
    • Constraint: One repository per aggregate root — not generic DAO for all tables.
    • Outcome: Domain unit tests with in-memory repository; storage swaps without rule changes.

    Internal architecture

    Uber trip persistence layering — repository as port:

    • Hexagonal architecture: repository is outbound port.
    • Integration tests hit CassandraTripRepository; domain tests use FakeTripRepository.
    text
    Application Service
    ↓ calls
    TripRepository (interface — domain module)
    ↓ implements
    CassandraTripRepository (infra module)
    ├─ TripPersistenceMapper
    ├─ Cassandra session + prepared statements
    └─ OutboxWriter (same transaction boundary)
    Trip aggregate ← never imports Cassandra driver

    Data flow

    Load path: service calls repo.findById(TripId) → mapper rehydrates aggregate from rows → domain methods execute → repo.save(aggregate, expectedVersion) → mapper to persistence model → write with LWT version check → outbox events.

    • Find: Primary key lookup only on write repo — O(1) by TripId.
    • Save: Optimistic locking column; conflict throws AggregateVersionConflict.
    • Read reports: TripSummaryQueryService hits read replica — not repository.

    System design diagram

    Two diagrams show the Repositories topology and the primary request/event path used in production at scale.

    Repositories — system view
    Application svc
    Edge
    TripRepository
    Core
    Mapper
    Data
    Cassandra
    Async
    High-level topology for Repositories.
    Repositories — request / event flow
    Load ID
    Ingress
    Map to domain
    Store
    Domain logic
    Store
    Map + save
    Emit
    Follow this path when reviewing production designs.

    Production code example

    Cassandra trip repository adapter — Java infrastructure implementation:

    • LWT IF version implements optimistic concurrency — domain stays free of Cassandra syntax via mapper tests.
    • Outbox publish after successful LWT — events only on committed state.
    java
    public class CassandraTripRepository implements TripRepository {
    private final CqlSession session;
    private final TripPersistenceMapper mapper;
    @Override
    public Optional<Trip> findById(TripId id) {
    Row row = session.execute(
    "SELECT * FROM trips WHERE trip_id = ?",
    uuid(id.value())
    ).one();
    return row == null ? Optional.empty() : Optional.of(mapper.toDomain(row));
    }
    @Override
    public void save(Trip trip, long expectedVersion) {
    TripRow row = mapper.toPersistence(trip);
    boolean applied = session.execute(
    """
    UPDATE trips SET status = ?, fare = ?, version = ?
    WHERE trip_id = ? IF version = ?
    """,
    row.status(), row.fareMicros(), row.version(),
    uuid(trip.id().value()), expectedVersion
    ).wasApplied();
    if (!applied) throw new AggregateVersionConflict(trip.id());
    outbox.publish(trip.pullEvents());
    }
    }

    Enterprise case study

    Uber TripRepository extraction: Persistence leakage caused fare bugs and slow domain tests.

    • Before: CQL in twelve services; Brazil pooled fare bug; domain tests 90s with Testcontainers.
    • Decision: TripRepository interface, Cassandra adapter, fake for unit tests, mapping tests.
    • After: Fare logic changes single location; domain tests 200ms; zero mapper omission bugs in 12 months.

    Trade-offs

    • Repository vs active record: Active record faster early; repository pays off at domain complexity threshold.
    • Generic repo anti-pattern: Repository<T> for all entities — loses aggregate mapping control.
    • ORM in repository: Acceptable in adapter — never expose EntityManager to domain.
    • Event sourcing: Repository loads by replaying events — different adapter, same interface to domain.

    Security considerations

    Repository enforces access at load: findById checks tenant/driver authorization before returning aggregate — or return null to prevent enumeration.

    • Encryption: Repository adapter encrypts PII columns at rest — domain sees decrypted VO inside aggregate only.
    • Audit: Save path logs actor and aggregate version — tamper-evident trail.
    • SQL injection: Prepared statements only in adapter — domain never builds queries.

    Scalability analysis

    Repository implementation scales storage — domain interface stable while sharding Cassandra, adding read replicas, or migrating to CRDB.

    • Shard awareness: Repository resolves TripId to partition — hidden from domain.
    • Connection pools: Infra tunes pool; domain unaware.
    • Batch save anti-pattern: Do not batch unrelated aggregates — breaks consistency boundary.

    Failure scenarios

    Repository failures: mapper drops fields; save without version check; N+1 load inside mapper; repository called for report queries overloading OLTP.

    • Partial rehydrate: Mapper misses new field — add round-trip mapping tests.
    • Lost update: No optimistic lock — two drivers accept same trip — version column mandatory.
    • Query creep: findByDriverAndDate on write repo — move to read model before index explosion.

    Staff engineer insights

    • Repository per aggregate root — if your repository has twenty unrelated find methods, it's a leaked DAO.
    • The fake in-memory repository is the fastest domain test investment — write it day one, not after first outage.
    • Read models are not repositories — querying for dashboards through write repo destroys OLTP and confuses boundaries.

    Interview questions

    Interview Prep

    Practice concise answers, then expand each card for the explanation.

    3 questions
    1AdvancedQuestionRepository vs DAO vs active record — when each?+

    Answer

    DAO: table-centric data access, anemic. Active record: entity saves itself — OK for simple CRUD apps. Repository: aggregate-centric, hides persistence, domain-driven — choose when domain rules and testing matter, e.g., Uber trip lifecycle.

    Follow-up

    Is Spring Data JpaRepository a repository?
    2AdvancedQuestionWhere do complex list/search queries go?+

    Answer

    Read side: dedicated query service, materialized view, Elasticsearch — not write repository. Write repo loads/saves aggregate by ID. CQRS split keeps OLTP path fast and repository interface honest.

    Follow-up

    Exception for admin repair tool?
    3AdvancedQuestionDesign repository for event-sourced aggregate.+

    Answer

    Interface unchanged: findById replays events from event store snapshot+stream; save appends events atomically with optimistic stream version. Domain unchanged when switching from CRUD to ES — adapter swap only.

    Follow-up

    Snapshot frequency trade-off?

    Architecture review questions

    • Is there one repository interface per aggregate root?
    • Does domain layer depend only on repository interface — not ORM/driver?
    • Are mapper round-trip tests present for persistence model?
    • Is optimistic concurrency enforced on save?
    • Are report queries excluded from write repository?
    • Is in-memory fake repository available for fast domain unit tests?

    Summary

    Repositories at Uber isolate trip aggregate persistence behind TripRepository — Cassandra adapter, optimistic versioning, and in-memory fakes — so domain rules evolve without CQL leakage or fare-calculation bugs from scattered queries.

    Ready to mark this lesson complete?Track your journey across the entire course.