How to Write Good Code Abstractions—The Art of Trimming Code
One of the things I enjoy in writing code is writing code abstractions. When implemented well, they result in clean code. Readable. Maintainable. Less "code dread", more "joy in creating".
I haven't tended for a bonsai tree—but I can imagine writing abstractions to be like it. Trim until they're pleasing to the eyes. Remove the unwanted parts. Grow it to elegance.
In this post, I'll share some properties of code abstractions I found to be pleasing to the eyes.
Reflect the abstract into concrete
class LendingContract(models.Model)
class LendingContractDocGen(PDFDocGen)
class Investor(models.Model)
class InvestorContractDocGen(PDFDocGen)
class InvestorDeposit(models.Model)
class InvestorWithdraw(models.Model)
Without seeing the implementation details, we can see that we're working with "entities". These entities, while each have their own responsibilities, all work together. Sub-systems that create a large system.
A LendingContract
stored in the database abstracts an actual legal agreement in the real world. An Investor
stored in the database holds the record of an individual involved in the business.
Entities are swappable. Good structures allow that.
class LendingContractDocGenV2(PDFDocGen):
template = "contracts/lending_contract_v2.html"
# lenders/views.py
def generate_contract_file(request, id):
...
# Replaced the contract used in the controller
contract.file = LendingContractDocGenV2(contract).\
get_encrypted_file(contract.passphrase)
In the example above, I declared LendingContractDocGenV2
then swapped out the previously set LendingContractDocGen
with it.
You could even take it up a notch by making the generate_contract_file()
class-based and setting the DocGen as a class-variable:
class GenerateContractView(View):
# swap LendingContractDocGenV2 here
contract_docgen = LendingContractDocGen
def post(self, request):
contract = LendingContract.objects.get(id=id)
contract.file = self.contract_docgen(contract)\
.get_encrypted_file(contract.passphrase)
contract.file.save()
contract.save()
Hide away sub-system implementation details
# app/docgen.py
import pdfkit
from django.template.loader import render_to_string
class PDFDocGen(object):
template = "docgens/default_template.html"
context = {}
def __init__(self, subject):
self.subject = subject
def get_template(self):
return self.template
def get_context_data(self):
return context
# XXX: Complex bits start here
def get_file(self):
context = self.get_context()
template = self.get_template()
html_contents = render_to_string(template, context)
pdf_contents = pdfkit.from_string(
input=html_contents,
output_path=None,
options=self.get_layout_settings()
)
return io.BytesIO(pdf_contents)
def get_encrypted_file(self, passphrase):
from PyPDF2 import PdfFileReader, PdfFileWriter
file = self.get_file()
input_pdf = PdfFileReader(file)
output_pdf = PdfFileWriter()
output_pdf.append_pages_from_reader(input_pdf)
output_pdf.encrypt(passphrase)
with io.BytesIO() as output_stream:
output_pdf.write(output_stream)
output_stream.seek(0)
return output_stream.read()
Sub-system complexity should be hidden away from the system implementation.
In the code example above, document generation PDFDocGen
is a sub-system that has a complex function that produces a pdf file from a template PDFDocGen.get_file()
and another that produces an encrypted one PDFDocGen.get_encrypted_file(passphrase)
. This is irrelevant to the lending system we're building. We know we want to create encrypted PDF files, but the algorithm is irrelevant. We might need to reuse the sub-system in other areas of the system also (lender contracts, bond contracts, non-disclosure agreements, etc) As such, this detail should be abstracted.
One would write a utility function, then create partial functions for reusability. It would look like this:
# apps/utils.py
def generate_pdf_file(template, filename, context):
# Do complex bits here...
# lending/utils.py
lending_contract_pdf_file = partial(
generate_pdf_file,
"lending_contract.pdf",
"contracts/lending_contract.html"
)
While it works—reusable and extensible, I found it less beautiful to this alternative:
# lending/docgen.py
class LendingContractDocGen(PDFDocGen):
template = "contracts/lending_contract.html"
filename = "lending_contract.pdf"
def get_context_data(self):
return {
'signer_name': self.subject.signer_name,
}
# investors/docgen.py
class InvestorContractDocGen(PDFDocGen):
template = "contracts/investor_contract.html"
filename = "investor_contract.pdf"
def get_context_data(self):
return {
'signer_name': self.subject.signer_name,
}
Make system implementation behavior declarative
# lending/models.py
class LendingContract(models.Model):
signer_name = models.CharField(max_length=255)
passphrase = models.CharField(max_length=255)
file = models.FileField()
# lending/docgen.py
class LendingContractDocGen(PDFDocGen):
template = "contracts/lending_contract.html"
filename = "lending_contract.pdf"
def get_context_data(self):
return {
'signer_name': self.subject.signer_name,
}
# lending/views.py
def generate_contract_file(request, id):
contract = LendingContract.objects.get(id=id)
contract.file = LendingContractDocGen(contract)\
.get_encrypted_file(contract.passphrase)
contract.file.save()
contract.save()
Good abstractions encourage you to write system behavior more declaratively than imperatively. While there would still be instances of imperative code, it would be comprehensible that it explains how "entities" interact with each other.
The example above ties how the abstraction sits in between the Model and the Controller (or called "views.py" in Django-land).
The model LendingContract
declares the fields it has and how it maps to the database table.
The docgen LendingContractDocGen
declares with the template details and how overrides how it generates the context loaded into the template.
Finally, the generate_contract_file()
controller "controls the flow" of the interaction between these two objects. When it is called, it does exactly what it's supposed to do—it generates the lending contract and saves it into the LendingContract
table.
System control flow should always be in controllers.
I hope you found value in this post! Post in the comments section below how you write trim your code.
While I was only able to cover one pattern (Prototype pattern) in the examples above, there's a lot more you could apply in your practice—you can find them here: Refactoring Guru. It distills all the patterns described in the book written by the Gang of Four (look it up).