Where to put business logic in a Django application has always been a point of contention. Models, object managers, forms and/or serializers, and service functions are common options that I’ve personally seen in the projects that I’ve worked on. In this article, I’ll describe the latter option and why it is my preferred way in my projects.

What are Service Functions?

A Service Function is a Python function that conceptually maps to the Service Layer Pattern. It encapsulates the business logic for an application’s particular action (ex: register a user, add a friend, send a notification, etc.), in which it should ideally do one thing AND one thing only. In the context of Django, I create a services.py file within a Django app.

To illustrate, let’s say we’re building a library application that allows users to loan a book:

app
├── migrations
│   └── __init__.py
├── __init__.py
├── admin.py
├── apps.py
├── errors.py
├── models.py
├── services.py # service function definitions
├── tests.py
└── views.py
# app/services.py

from .models import User, Book, Loan
from .errors import UserHasDueLoans

def loan_book(
    *,                                      #[1]
    user: User,
    book: Book,
) -> Loan:                                  #[2]
    """
    Loans a given book to a given user.
    """
    if user.loans.filter(is_due=True):
        raise UserHasDueLoans()             #[3]
    
    loan = Loan.objects.create(             #[4]
        user=user,
        book=book,
    )

    return loan

The loan_book function is an example of a Service Function.

  1. It is defined as a kwargs-only function, which means callers of this function are forced to pass their arguments as keyword arguments. A contrived example: loan_book(user=a, book=b) leaves no ambiguity on the meaning of its arguments, while loan_book(a, b) doesn’t provide enough context unless you peek inside the function definition.
  2. Type hints are added for improved IDE support and acts as an additional documentation. At the very least it forces us to think what concrete types we expect the function to work with.
  3. Business errors are raised as custom errors, in which error handling is left to the caller.
  4. The business logic itself could start simple, but having it live inside a service function allows it to grow organically. Complex logic can be extracted to separate service functions to allow reuse and improve maintainability.

What makes Service Functions good?

Given that they’re just plain functions, Service Functions are easily testable, promotes reuse and composition. They’re very unassuming to start writing out – it’s not uncommon to flesh out your system’s API with a bunch of skeletal service function calls:

# views.py

def monthly_loan_report(request):
    try:
        overdue_fess = compute_overdue_fees()
        total_sales = compute_total_sales(overdue_fess=overdue_fess)
        best_sellers = compute_best_seller_books()
        report = generate_loan_report(total_sales=total_sales, best_sellers=best_sellers)
        pdf = generate_loan_report_pdf(report=report)
        send_loan_email_reports(report=pdf, recipients=settings.LOAN_ADMINS)
    except SMTPError:
        raise NotImplementedError("TODO: Implement me!")
    except NotEnoughBookData:
        raise NotImplementedError("TODO: Implement me!")
    except PDFGenerationError:
        raise NotImplementedError("TODO: Implement me!")
    
    return Response(data, status=200)

…in which these service calls are defined with the same skeletal structure:

# services.py

def compute_overdue_fees() -> float:
    raise NotImplementedError("TODO: Implement me!")

def compute_total_sales(*, overdue_fess: float) -> float:
    raise NotImplementedError("TODO: Implement me!")
    
def compute_best_seller_books() -> List[Book]:
    raise NotImplementedError("TODO: Implement me!")
    
def generate_loan_report(
    *,
    total_sales: float,
    best_sellers: List[Book],
) -> LoanReport:
    raise NotImplementedError("TODO: Implement me!")

def generate_loan_report_pdf(*, report: LoanReport) -> BytesIO:
    raise NotImplementedError("TODO: Implement me!")
    
def send_loan_email_reports(
    *,
    report: BytesIO, 
    recipients: List[str],
):
    raise NotImplementedError("TODO: Implement me!")

Conclusion

At its core, the main advantage of service functions is that if you ever want to look for business logic-related code, you only need to look at one file: services.py. Having a central catalog of how your entire system works in a functional level allows simple discoverability, a straightforward on-boarding process for new developers, and a smaller surface area for maintenance.