patternIntermediateArchitecture

Service Repository Pattern

Problem
Applications with business logic tightly coupled to database code become hard to test, maintain, and evolve as requirements change.
Solution
Separate concerns by introducing a Service Layer for business logic and a Repository Layer for data access, with clear interfaces between them.
Trade-offs
  • Adds architectural complexity for simple CRUD applications
  • Requires discipline to maintain separation
  • Initial development is slower than direct database access

Problem

Monolithic applications often mix business logic with database operations:

// ❌ Tightly coupled code
class UserController {
  async getUser(id: string) {
    const user = await db.query('SELECT * FROM users WHERE id = ?', [id])
    if (!user) throw new Error('Not found')
    if (user.is_suspended) throw new Error('Account suspended')
    return user
  }
}

Issues:

  • Business logic (suspension check) mixed with data access (SQL query)
  • Hard to test without a real database
  • Can't reuse logic across different controllers
  • Difficult to swap databases or add caching

Solution

Separate concerns into distinct layers:

Repository Layer (Data Access):

interface UserRepository {
  findById(id: string): Promise<User | null>
  save(user: User): Promise<void>
}

class PostgresUserRepository implements UserRepository {
  async findById(id: string): Promise<User | null> {
    const row = await this.db.query('SELECT * FROM users WHERE id = ?', [id])
    return row ? this.toDomain(row) : null
  }
}

Service Layer (Business Logic):

class UserService {
  constructor(private repo: UserRepository) {}

  async getUser(id: string): Promise<User> {
    const user = await this.repo.findById(id)
    if (!user) throw new UserNotFoundError(id)
    if (user.isSuspended) throw new AccountSuspendedError(user.id)
    return user
  }
}

Controller (Coordination):

class UserController {
  constructor(private service: UserService) {}

  async handleGetUser(req: Request): Promise<Response> {
    const user = await this.service.getUser(req.params.id)
    return { status: 200, body: user }
  }
}

Adding Caching Layer

Enhance performance by adding cache between Service and Repository:

class CachedUserRepository implements UserRepository {
  constructor(
    private cache: Cache,
    private repo: UserRepository
  ) {}

  async findById(id: string): Promise<User | null> {
    // Check cache first
    const cached = await this.cache.get(`user:${id}`)
    if (cached) return cached

    // Cache miss: query database
    const user = await this.repo.findById(id)
    if (user) {
      await this.cache.set(`user:${id}`, user, { ttl: 300 })
    }
    return user
  }
}

Benefits

  1. Testability: Mock repositories for unit testing
  2. Maintainability: Changes to data access don't affect business logic
  3. Flexibility: Swap databases without changing services
  4. Reusability: Services can be used by multiple controllers
  5. Performance: Easy to add caching without changing business logic

Real-World Examples

Amazon: Product catalog service uses repository pattern with Redis caching, reducing database load by 90%.

Netflix: Content metadata service abstracts Cassandra, allowing migration to other stores without service changes.

Uber: Ride service separates business rules from geo-spatial database queries, enabling independent optimization.

When to Use

Use this pattern when:

  • Business logic is complex
  • Multiple controllers need same data operations
  • System requires comprehensive testing
  • Data sources may change
  • Performance optimization (caching) is needed

Skip this pattern when:

  • Application is simple CRUD
  • Using an ORM that already provides abstraction
  • Team is small and architecture discipline is low
  • Deadlines are tight and complexity isn't justified

Implementation Checklist

  • Define repository interfaces (CRUD operations)
  • Implement repository for chosen database
  • Create service layer with business logic
  • Use dependency injection for testability
  • Write unit tests with mocked repositories
  • Add caching layer if needed
  • Document repository contracts
  • Handle errors at appropriate layers

Common Pitfalls

Over-abstraction: Don't create repositories for every table—group related data operations.

Leaky abstractions: Don't expose database-specific types (e.g., SQL result objects) from repositories.

Anemic services: Services that just pass through to repositories add no value.

Generic repositories: One-size-fits-all repositories are hard to use and maintain.

Trade-offs

Pros:

  • Clean separation of concerns
  • Easy to test
  • Flexible and maintainable

Cons:

  • More code to write initially
  • Requires architectural discipline
  • Overkill for simple applications

This pattern is not about perfection—it's about managing complexity as systems grow.

Related Content