One of the most immediately-beneficial principles of writing SOLID code is the Single Responsibility Principle. The official definition is “Any ’entity’ (i.e. a class, method, function, arguably module, etc.) should have only one reason to change.”
One interpretation of this is that any ’entity’ should ‘depend’ on just one layer.
If you’re writing logic in a PHP framework like Laravel, your controller should “officially” only interact with the HTTP layer: the request and its data.
No matter the language, it’s critical that each aspect of your codebase has just one ‘job’. Any more, and your code will be harder to test, riskier to change, and require greater mental effort to understand and maintain by other engineers—or you in six months.
Whether it’s a module, class, or function, it should only have one responsibility.
Take this example:
def generate_invoice(items: list[Item], customer: Customer) -> Invoice:
subtotal = sum(item.price * item.quantity for item in items)
tax = round(subtotal * 0.08, 2)
total = round(subtotal + tax, 2)
invoice = Invoice(subtotal, tax, total, customer)
pdf = SomePackageDependency(invoice)
pdf.set_font('Arial', 12)
pdf.add_header(invoice.customer.name)
pdf_path = pdf.generate()
s3_client = boto3.client('s3')
s3_client.upload_file(
pdf_path,
f"{customer.uid}",
f"{invoice.uid}.pdf",
ExtraArgs={'ContentType': 'application/pdf'}
)
smtp_server = smtplib.SMTP('smtp.gmail.com', 587)
smtp_server.starttls()
smtp_server.login(os.getenv("EMAIL_USER"), os.getenv("EMAIL_PASS"))
smtp_server.sendmail(os.getenv("EMAIL_FROM_ADDRESS"), customer.email, invoice.render())
return invoice
In one function, we’re:
- Calculating invoice information
- Generating a PDF
- Uploading the PDF to S3
- Sending an email with the PDF attachment
If this is a small project, this might be completely fine. SRP is a guideline, not a rule. But if you’re like most engineers, you’re working in larger project with significantly-more complexity. This could easily happen:
- The business requirements around what items are taxed changes
- Due to infra changes, PDF generation is now done via 3rd-party service
- Legal now requires that a copy of each invoice PDF is uploaded to a disaster recovery service.
- The existing S3 buckets are being deprecated in-favor of division-by-tenant
- The API for the email service is being changed as part of a major version bump
Any one of these tasks could easily-double the complexity of this function.
Let’s apply SRP:
def calculate_invoice(items: list[Item], customer: Customer) -> Invoice:
subtotal = sum(item.price * item.quantity for item in items)
tax = round(subtotal * 0.08, 2)
total = round(subtotal + tax, 2)
invoice = Invoice(subtotal, tax, total, customer)
return invoice
def generate_pdf(invoice: Invoice) -> str:
pdf = SomePackageDependency(invoice)
pdf.set_font('Arial', 12)
pdf.add_header(invoice.customer.name)
pdf_path = pdf.generate()
return pdf_path
def upload_to_s3(path: str, bucket: str, key: str):
s3_client = boto3.client('s3')
s3_client.upload_file(
path,
bucket,
key,
ExtraArgs={'ContentType': 'application/pdf'}
)
def send_email(email: str, body: str):
smtp_server = smtplib.SMTP('smtp.gmail.com', 587)
smtp_server.starttls()
smtp_server.login(os.getenv("EMAIL_USER"), os.getenv("EMAIL_PASS"))
smtp_server.sendmail(os.getenv("EMAIL_FROM_ADDRESS"), email, body)
# This function's responsibility is now orchestration
def generate_invoice(items: list[Item], customer: Customer) -> Invoice:
invoice = calculate_invoice(items, customer)
pdf_path = generate_pdf(invoice)
upload_to_s3(pdf_path, f"{customer.uid}", f"{invoice.uid}.pdf")
send_email(customer.email, invoice.render())
return invoice
Note that calling multiple functions isn’t always a bad thing—it’s the mixing of different levels of abstraction that will cause problems. The refactored logic still calls functions, only now the generate_invoice function has just one responsibility: orchestration.
Does the ‘after’ have more lines of code? Of course. But if this is a larger project, this refactoring will make maintenance easier for other engineers, and reduce the likelihood of bugs introduced in the future.
Caveats
A common gotcha I’ve seen with junior/mid-level engineers is the desire to ‘cargo-cult’ this principle—they apply it in every possible area, noting its absence in code reviews, and so forth.
This is premature optimization, and can dramatically bloat a codebase. Fight the urge to apply this on an absolute basis; you should generally default to a simpler solution until a new requirement (or obvious code-bloat) forces you to consider a refactor.
Summary
- Prefer more, smaller functions over fewer, larger ones
- Avoid premature optimization by deferring abstraction until necessary
- Don’t mix different levels of abstraction