Untangling the Django Model Monolith: A Guide to Extracting Concerns
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
pass
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
pass
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)
loan.contract_file.set_file(loan_contract.generate_file())
loan.save()
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)
underwriter.underwrite()
# Do this
def process_underwriting(request, loan_id):
loan = get_object_or_404(Loan, id=loan_id)
underwriter = Underwriter(loan)
underwriter.underwrite()
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)
underwriter.approve()
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