Untangling the Django Model Monolith: A Guide to Extracting Concerns

The iconic banyan tree in Pipiwai, Hawaii (Credits: Brandon Green from Unsplash)

Our Starting Point: The Overburdened Loan Model

Imagine the Loan model as a bustling city center: it's crowded, chaotic, and handling way more than it was designed for. This model is no different. It acts like a monolithic structure, struggling under the weight of its multiple responsibilities. Take a look:

class Loan(models.Model):
    borrower = ForeignKey(Borrower)
    beneficiary = ForeignKey(Student)
    guarantor = ForeignKey(Guarantor)
    principal = MoneyField()
    loan_product = ForeignKey(LoanProuduct)
    # Underwriting functions
    def underwrite(self): ...

    # Documents to be generated
    def generate_contract(self): ...
    def generate_disclosure_statement(self): ...

    # Monitoring functions
    def get_dpd_status(self): ...
    def get_behavioral_score(self): ...
    # Email notification functions
    def send_repayment_reminder(self): ...
    def send_payment_acknowledgement(self): ...

The code above is merely an outline, a folded version of what is a behemoth in reality—imagine that each method expands to dozens or even hundreds of lines of code. Collectively, we're talking about a file that can easily exceed 2,000 lines.

The model is responsible for underwriting loans, generating various types of documents, monitoring loan performance, and even managing email notifications. This amalgamation of tasks makes it difficult to manage, and its extensive list of dependencies can be paralyzing.

In essence, our Loan model is like a jack-of-all-trades but a master of none. It's cumbersome to navigate, prone to errors, and a real challenge for any developer to work with. We need a strategy to disentangle this Gordian knot of code.

Step-by-Step: Extracting the Document Generation Concerns

We can see a trend in our model. It is concerned with three categories of behaviors: underwriting, document generation, and monitoring. Let's extract the concern of document generation:

Step 1: Identify Methods for Extraction

First, pinpoint the methods within your model related to document generation. In the case of the Loan model, these methods could be generate_contract and generate_disclosure_statement.

Step 2: Create the Docgen Base Class

Create a new file, app/docgens.py, and within it, define the Docgen class. This will act as the base class for all your document generators.

# app/docgens.py
class Docgen(object):
    template_name = None

    def __init__(self, subject):
        self.subject = subject

    def get_template_name(self):
        return self.template_name

    def get_context(self):
        return {}

    def generate_file(self):
        # File generation logic

Step 3: Subclass for Specific Documents

Create specific subclasses for each document you wish to generate. In your case, you'll want a LoanContractDocgen and a LoanDisclosureDocgen.

# loans/docgens.py
from app.docgens import Docgen

class LoanContractDocgen(Docgen):
    template_name = "contracts/loan_contract.html"

    def get_context(self):
        # Build context specific to Loan Contract

class LoanDisclosureDocgen(Docgen):
    template_name = "contracts/loan_disclosure.html"

Step 4: Update Views

Update your views to utilize the new Docgen concern. Remove the old methods from the Loan model, and update your view functions.

# loans/views.py
from .docgens import LoanContractDocgen

def generate_contract(request, loan_id):
    loan = get_object_or_404(Loan, id=loan_id)
    loan_contract = LoanContractDocgen(loan)

Step 5: Move Dependencies

Move any dependencies that are only required for document generation into app/docgens.py. This helps isolate dependencies related to each concern.

We still have a few concerns left inside our Loan model, and it's now your turn to extract them. Let me give you some guiding principles to help you proceed.

Extracting Model Concerns: Guiding Principles

Guiding Principle #1: Models are Stable Building Blocks

Rather than feeding individual attributes to the concern, just pass the entire model instance. Attributes may change over time, but the model itself is a stable interface.

# Instead of this
docgen = LoanContractDocgen(loan.principal, loan.borrower, loan.guarantor)

# Do this
docgen = LoanContractDocgen(loan)

Guiding Principle #2: Models are the Core Subject

Concerns operate on data, and the most coherent place to pull this data from is the model itself. Think of concerns as machines that use the model as an input.

# Monitoring concern that uses Loan model as its subject
monitor = LoanMonitor(loan)
dpd_status = monitor.get_dpd_status()

Guiding Principle #3: Keep Concerns Out of Models

Once you've removed a concern from a model, don't put it back. Use the concerns where they are needed.

# Don't do this
class Loan(models.Model):
    def underwrite(self):
        underwriter = Underwriter(self)

# Do this
def process_underwriting(request, loan_id):
    loan = get_object_or_404(Loan, id=loan_id)
    underwriter = Underwriter(loan)

Guiding Principle #4: Concerns are Not a Novelty

The concept of extracting concerns is nothing new. It aligns with established patterns like forms, serializers, and querysets.

# serializers.py - Concern for serialization
class LoanSerializer(serializers.ModelSerializer):
    class Meta:
        model = Loan
        fields = '__all__'

# forms.py - Concern for form handling
class LoanForm(forms.ModelForm):
    class Meta:
        model = Loan
        fields = '__all__'

Guiding Principle #5: Encapsulation Over Exposure

Prefer to encapsulate logic within concerns, rather than exposing model attributes and methods. This way, you don't accidentally manipulate model data in ways that are inconsistent with the business logic.

# Instead of directly modifying model attributes
loan.status = "Approved"

# Use a concern to encapsulate this logic
underwriter = Underwriter(loan)

Guiding Principle #6: Make Concerns Stateless

Concerns should be stateless, meaning they should not maintain state between function calls. This makes the code easier to reason about and test.

# Don't store state in the concern
class BadUnderwriter(object):
    def __init__(self, loan):
        self.loan = loan
        self.is_approved = False  # Stateful

# Do make it stateless
class GoodUnderwriter(object):
    def __init__(self, loan):
        self.loan = loan
    def approve(self):
        self.loan.status = "Approved"

Guiding Principle #7: Use Composition Over Inheritance

When you need to share behavior across different concerns, use composition rather than inheritance. This will allow you to mix and match functionalities more freely.

class DisclosureWithSignature(LoanDisclosureDocgen):
    def get_context(self):
        context = super().get_context()
        context['signature'] = SignatureComponent().get_signature(self.loan)
        return context

By incorporating these guiding principles into your development practices, not only do you enhance the maintainability and reliability of your codebase, but you also pave the way for a more sustainable and scalable software architecture.

Django's framework is already well-architected and highly extensible, but when you integrate the concept of concerns, you add another layer of scalability and extensibility. You'll find that navigating and extending your code becomes a more manageable endeavor, and new team members can more easily understand the architecture