Python

Python Design Patterns Spotlight: Service layer + Repository + Spefication Patterns

Zachary Carciu 8 min read

Python Design Patterns Spotlight: Service layer + Repository + Spefication Patterns

Many problems you will face as a software developer, engineer, coder, programmer, whatever you want to call it, have been faced by many others who came before you. Design patterns are standard solutions to common problems in software design. In Python, you can implement them in a clean and flexible way thanks to its object-oriented and dynamic features. In this article, we will talk about three domain-driven design (DDD) patterns especially useful in layered architectures: Repository, Specification, and Service Layer.

These patterns, when used together, create a harmonious architecture that separates concerns, enhances maintainability, increases testability, and brings clarity to complex business logic.


1. Repository Pattern

Purpose:

To abstract and encapsulate data access logic, making your domain logic independent from data persistence.

Key Ideas:

  • Provides a collection-like interface for accessing domain objects.
  • Hides queries and ORM logic (e.g., SQLAlchemy, Django ORM).
  • Encourages testable and decoupled code.

Why It Matters:

The Repository pattern acts as a bridge between your domain model and data source, creating a clean separation that protects your business logic from the details of how data is stored and retrieved. This separation allows the ability to swap out database technologies without affecting your core code, unit test business logic without database dependencies, and maintain a more focused codebase where domain objects remain pure and uncontaminated by persistence concerns.

When using the Repository pattern, only classes in the repository layer should be accessing the database. Instead of littering your business logic code with SQL queries or ORM-specific operations, you interact with meaningful methods like find_active_users() or save_customer_order(). This collection-like interface feels natural to domain experts and developers alike, creating a more intuitive codebase.

Example:

class UserRepository:
    def __init__(self, session):
        self.session = session

    def get_by_id(self, user_id: int):
        return self.session.query(User).filter(User.id == user_id).first()

    def add(self, user: User):
        self.session.add(user)

    def list(self):
        return self.session.query(User).all()

Practical Guidelines:

  • Session Management: Repositories should always be initialized with a database session
  • Single Responsibility: Each repository should focus on a single entity or closely related group of entities
  • Operation Coverage: Include methods for both basic CRUD operations and specialized queries
  • Query Encapsulation: Use repositories to hide complex SQL logic and data transformations
# Example of a specialized repository method
def get_most_recent_price_date(self, symbol):
    result = (self.session
        .query(func.max(StockPrice.date))
        .join(Stock)
        .filter(Stock.ticker==symbol)
        .one_or_none())
    return result[0] if result else None

2. Specification Pattern

Purpose:

To encapsulate query logic and business rules in reusable, combinable objects.

Key Ideas:

  • Used to check if an object satisfies some criteria.
  • Can be composed using logical operations like AND, OR, NOT.

Why It Matters:

The Specification pattern transforms business logic from scattered if-else statements into a standardized set of rules in your codebase. This transformation elevates business logic from implicit code to explicit objects that can be named, composed, and reused across your application. By packaging selection criteria into discrete specification objects, you gain the power to build complex queries from simple building blocks—almost like creating a specialized language for your domain’s selection rules.

This pattern truly shines when business rules are complex or evolving. Especially in data heavy applications like finance. Rather than untangling nested conditionals spread throughout your codebase, you can isolate and test each specification independently. When requirements change, you modify one specification and the change propagates throughout the system. This isolation of concerns makes your code more adaptable to changing business needs and easier to maintain over time.

Example:

class Specification:
    def is_satisfied_by(self, candidate):
        raise NotImplementedError

class HasActiveSubscription(Specification):
    def is_satisfied_by(self, user):
        return user.subscription and user.subscription.active

class IsOver18(Specification):
    def is_satisfied_by(self, user):
        return user.age >= 18

# Combining specs
class AndSpecification(Specification):
    def __init__(self, *specs):
        self.specs = specs

    def is_satisfied_by(self, candidate):
        return all(spec.is_satisfied_by(candidate) for spec in self.specs)

# Usage
spec = AndSpecification(HasActiveSubscription(), IsOver18())
if spec.is_satisfied_by(user):
    print("User is eligible")

Practical Guidelines:

  • Reusability: Create specifications that can be reused across different queries and repositories
  • Single Criterion: Focus each specification on a single filtering criterion when possible
  • Judicious Composition: Use composition sparingly and only when necessary for complex queries
  • Domain Language: Name specifications to align with your domain language and business rules
# Using specifications with repositories for data analysis
def get_df(self, specs: [Specification]) -> pd.DataFrame:
    results = self.filter(specs)
    df = pd.DataFrame(results, columns=['reference_time', 'open_price'])
    return df

3. Service Layer Pattern

Purpose:

To encapsulate business logic and orchestrate operations involving multiple domain entities or repositories.

Key Ideas:

  • Prevents bloated models and views/controllers.
  • Coordinates domain entities and repositories.

Why It Matters:

The Service Layer pattern provides a home for your application’s workflow logic. These are operations that don’t naturally belong to a single entity but instead represent a business process involving multiple components. Without services, this orchestration logic often ends up scattered across controllers, models, or utility functions, creating a tangled web that’s difficult to understand, test, and maintain.

Services coordinate your domain objects to perform business operations, creating a clean separation between “how to do something” (domain entities) and “when and why to do it” (services). This division of responsibilities leads to a more modular codebase where each component has a clear purpose and the service layer itself becomes a readable expression of your application’s core workflows.

Example:

class UserService:
    def __init__(self, user_repo, email_service):
        self.user_repo = user_repo
        self.email_service = email_service

    def register_user(self, user_data):
        user = User(**user_data)
        self.user_repo.add(user)
        self.email_service.send_welcome_email(user)

Practical Guidelines:

  • Orchestration Role: Services should coordinate between repositories and domain logic
  • Dependency Injection: Inject dependencies via constructor rather than creating them internally
  • Transaction Management: Handle transactions at the service level when operations span multiple repositories
  • Use Case Focus: Keep service methods focused on specific business workflows
def _run_strategy(self, params: Dict[str, Any], underlying_symbol: str, 
                  start_date: date, end_date: date) -> pd.DataFrame:
    strategy_type = params['strategy_type']
    
    # Coordinating with repositories
    puts_and_calls = self.options_price_repo.get_0dte_prices(
        specs=[], symbol=underlying_symbol, after=start_date, before=end_date
    )
    
    # Coordinating with domain logic
    if strategy_type == 'strangle':
        strategy = StrangleStrategy(params=dict(strike_widths=params['strike_widths']))
    elif strategy_type == 'iron_condor':
        strategy = IronCondorStrategy(params=dict(
            body_widths=params['body_widths'], wing_widths=params['wing_widths']))
    
    # Execute domain logic with data from repositories
    strategy_prices = self._execute_strategy(strategy, {...})
    return strategy_prices

How They Work Together

In a well-structured app:

  • Repositories handle data access.
  • Specifications define filtering or eligibility rules.
  • Service Layer coordinates high-level operations.

This separation supports:

  • Easier testing and mocking
  • Clean domain logic
  • Flexible persistence strategies

Conclusion

Repository, Specification, and Service Layer each solve a different piece of the same puzzle: data access, business rules, and workflow coordination. Used together, they keep code organized, testable, and ready for change. If your project is starting to feel tangled, try isolating those concerns behind these patterns and you’ll notice cleaner boundaries and simpler tests almost immediately. I hope this helps explain the Repository, Specification, and Service Layer patterns. Good luck building!