Identify and Manage Software Complexity
Reading Only

To appreciate software design and grasp the Object-Oriented Programming paradigm, you must understand the challenge of building software systems.

As a software engineer, you're expected to build software that automates business processes in a way that's cost-effective to change. Automating processes and meeting requirements with code is just the start; the true challenge is writing code that's easy to understand, test, and modify in the future, whether by yourself or fellow engineers.

As we craft our code, we should do so with empathy so that others can read and modify it with ease. Life is hard enough; let's not make it worse by writing code that's hard to understand. Easier said than done, right? The challenge at hand is complexity.

Baseline Complexity

What is complexity, and where in the codebase is it? Is it any of these?

  • The complicated mathematical formula provided by our sales team to project next year's revenue
  • The jargon-filled API request that must be built and sent to downstream systems
  • The business rules that must be evaluated before the API request is sent

It's all of those. They're the baseline complexity of the problem we're solving. As engineers, even though we're stakeholders, our capacity to manage this type of complexity is limited. Assuming the requirements are well-considered, they stand as they are, and we have no power to alter them. Baseline complexity is the complexity we have to live with.

Implementation Complexity

As we dive into implementation, we encounter another layer of complexity known as implementation complexity. This is something we can foresee, plan for, and ideally manage. However, it's a continuous and incremental challenge that accompanies every business requirement.

Think about a Salesforce Flow that starts to become overwhelming if we don't regularly check its size and purpose whenever we update it. At first, building and testing the flow might take just half an hour. But over time, as we keep adding to the flow's functionality, it could take hours to build and test. This build-up slows us down as we're working against a growing backlog of tweaks and fixes—what we call technical debt. Now, picture facing this situation with Apex, where you're juggling thousands of lines of code. The complexity and the time needed can skyrocket. Yikes!

Let's not rush into learning OOP, Design Principles, Design Patterns, or Architectural Patterns just yet. Those are solutions for complexity. Let's first articulate how complexity occurs, how we can detect it, and how we can mitigate it. Once we figure that out, we'll have the context we need to learn everything else because we'll know what problem we're trying to solve.

Decomposition

When faced with big problems, we look for ways to break the problem down into smaller problems that are easier to understand and solve. In computer science, this process is formally referred to as decomposition.

Let's say you're reviewing code that sets an Opportunity to "Closed/Won". The code validates the records, updates the records, logs exceptions, and sends emails. Consider the below example ( a mix of code and pseudocode) as the implementation:

public class OpportunityService { public void setClosedWon(List<Opportunity> opportunities){ // Loop through opportunities // Check to see if the record can be closed // If so, set StageName to Closed/Won // Send Closed/Won email // Handle any exceptions and log them } private Boolean canClose(Opportunity oppty){ // business rules to check if the opportunity can close // logic omitted } private void sendEmail(String toAddress, String subject, String body){ // logic to send email Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage(); mail.setToAddresses(toAddress); mail.setSubject(subject); mail.setPlainTextBody(body); Messaging.sendEmail(new Messaging.SingleEmailMessage[] { mail }); } private void handleExceptions(String errorMessage, Id recordId){ // logic to create an exception log sObject record Exception_Log__c log = new Exception_Log__c(); log.error_messsage__c = errorMessage; log.record_id__c = recordId; insert log; } public void setClosedLost(List<Opportunity> opportunities){ // closed lost implementation } public void calculateWeightedRevenue(){ ... } }



Each of these methods performs a specific task within the more extensive functionality of the OpportunityService class. Imagine how hard it would be to read this code if all of this logic were in a single method called setClosedWon(). Decomposition allows you to divide the code into smaller, reusable parts.

There is baseline complexity involved in canClose() that can't be avoided because of business rules we can't control. There's also baseline complexity with sendEmail() because of the boilerplate code required to send an email in Apex.

Cohesion

The code above is organized, but the class feels overcrowded; it's doing too much. Not because there are too many methods but rather because of how related the methods are to each other.

When creating a class, we're creating a logic unit. We want this unit to be cohesive, to convey an idea with as little information as possible. All the information used to describe the unit should be relevant and directly related. What's missing here is another layer of code that can abstract away ideas like sendEmail() and handleExceptions() so OpportunityService can focus on executing opportunity business rules. Let's do that.

To fix our cohesion problem, we will define a new class to handle exceptions called ExceptionLogger and a new class to handle emails called EmailService. Each class has its own responsibility and can be reused in other scenarios.

public class ExceptionLogger(){ public void handleExceptions(String errorMessage, Id recordId){ // logic to create an exception log sObject record Exception_Log__c log = new Exception_Log__c(); log.error_messsage__c = errorMessage; log.record_id__c = recordId; insert log; } }

public class EmailService(){ private void sendEmail(String toAddress, String subject, String body){ Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage(); mail.setToAddresses(toAddress); mail.setSubject(subject); mail.setPlainTextBody(body); Messaging.sendEmail(new Messaging.SingleEmailMessage[] { mail }); } }

Looking good! Let's wire these up with the OpportunityService:

public class OpportunityService { ExceptionLogger logger; EmailService emailService; public OpportunityService(ExceptionLogger logger, EmailService emailService){ this.logger = logger; this.emailService = emailService; } public void setClosedWon(List<Opportunity> opportunities){ ... // calls logger methods // calls emailService methods } private Boolean canClose(Opportunity oppty){ ... } public void setClosedLost(List<Opportunity> opportunities){ ... } }

Coupling

This alleviates the original problem, but it introduces a new one: coupling. Coupling refers to the degree of interdependency between two different classes. To create OpportunityService, we must pass in a concrete instance of logger and emailService, so we say that OpportunityService is dependent upon ExceptionLogger and EmailService.

These are concrete dependencies, which means a specific type has been instantiated and referenced. What's the big deal? Over time, this is where you'll start ripping your hair out.

When EmailService and ExceptionLogger change, they will cause ripple effects throughout the code. If there is a business capability added to OpportunityService that doesn't require logging or email, you'd still need to provide those dependencies. It also makes testing more tedious because there is no way to test OpportunityService in isolation. We'd need to pass in those dependencies whether our test cares about the email functionality or logging functionality. We've made life harder.

But this is a bad example on purpose! This is called tight coupling. It indicates that our classes are not independent of each other. With low coupling, each class is relatively independent and can be modified without affecting others. OOP can help promote low coupling by relying upon abstractions and abstract dependencies.

Coupling and Cohesion require a balance, but it'll never be a perfect balance of both. Most of the programming paradigms, design patterns, and design principles you'll see throughout your career are just opinionated ways of managing coupling and cohesion.