Managing software complexity – Simple is not easy

Recently I have seen a sentence “Simple solutions scale better, are easier to maintain and deliver value faster.” In this post I will show some examples, where initial code which seems fine to implement, may not be relevant when application grows and requires thinking in terms of scalability, maintainability and readability. Some parts of code will require accepting different trade offs, broader perspective on what needs to be achieved and definietly – more abstraction layers and sophisticated approach. The examples below are written in JavaScript, but I believe they may be understood by any software developer.   1. Imperative vs. Declarative Programming (describing logic) The Illusion of Simplicity (Imperative): Imperative programming focuses on how to achieve a result by explicitly stating the steps. It can seem simple for basic tasks. const numbers = [1, 2, 3, 4, 5]; const doubled = []; for (let i = 0; i < numbers.length; i++) { doubled.push(numbers[i] * 2); } console.log(doubled); // [2, 4, 6, 8, 10] The hidden complexity: As the logic becomes more intricate, imperative code can become verbose and harder to reason about. Managing state and side effects explicitly can lead to more opportunities for errors. The path to simplicity: Declarative programming focuses on what the result should be, abstracting away the control flow. Higher-order functions in JavaScript enable a more declarative style. const numbers = [1, 2, 3, 4, 5]; const doubled = numbers.map(number => number * 2); console.log(doubled); // [2, 4, 6, 8, 10] The map function abstracts away the iteration process, making the code more concise and easier to understand the intent – to double each number in the array. While the map function itself has underlying complexity, it provides a simpler interface for the developer. 2. Reduce vs Map and Filter This example will show that using more sophisticated code (which is more efficient) might cause a cognitive overwhelm. Let’s say we want to have an array of doubled numbers, only when the value is higher than 3. You may use 'reduce' for that it will loop through the array, multiply number if it is higher than 2, then push it to the output array. const numbers = [1, 2, 3, 4, 5]; const doubledHigherThanTwo = numbers.reduce((acc, number) => { if (number > 2) { acc.push(number * 2); } return acc; }, []); console.log(doubledHigherThanTwo); // [6, 8, 10] For this task you may also use filter and map combined, which seems more clear. It filters out numbers higher than 2, then makes multiplying on each element. const numbers = [1, 2, 3, 4, 5]; const doubledHigherThanTwo = numbers .filter(number => number > 2) .map(number => number * 2); console.log(doubledHigherThanTwo); // [6, 8, 10] Both of the solutions are fine and using reduce may be more flexible in future, but you can use the second approach, when code readability or maintainability (debugging) is your priority. 3. Factory design pattern Imagine you're building a system to manage different types of notifications (email, SMS, push). Creating a notification might involve setting up API keys, formatting the message, and potentially logging the creation. class EmailNotification { constructor(recipient, subject, body, apiKey) { this.recipient = recipient; this.subject = subject; this.body = body; this.apiKey = apiKey; this.setupEmailService(); this.formatMessage(); this.logCreation(); } setupEmailService() { console.log(`Setting up email service with API key: ${this.apiKey.substring(0, 5)}...`); // Imagine actual API client initialization here } formatMessage() { this.formattedBody = `Subject: ${this.subject}\n\n${this.body}`; console.log("Email message formatted."); } send() { console.log(`Sending email to ${this.recipient}:\n${this.formattedBody}`); // Imagine actual sending logic here } logCreation() { console.log(`Email notification created for ${this.recipient} at ${new Date().toLocaleTimeString()}.`); } } class SMSNotification { constructor(phoneNumber, message, accountSid, authToken) { this.phoneNumber = phoneNumber; this.message = message; this.accountSid = accountSid; this.authToken = authToken; this.setupSMSService(); this.truncateMessage(); this.logCreation(); } setupSMSService() { console.log(`Setting up SMS service with SID: ${this.accountSid.substring(0, 5)}...`); // Imagine actual API client initialization here } truncateMessage() { this.truncatedMessage = this.message.substring(0, 140); // Basic truncation console.log("SMS message truncated (if necessary)."); } send() { console.log(`Sending SMS to ${this.phoneNumber}: ${this.truncatedMessage}`); // Imagine actual sending logic here } logCreation() { console.log(`SMS notification created for ${this.phoneNumber} at ${new Date().toLocaleTimeString()}.`); } } // Client co

May 1, 2025 - 20:58
 0
Managing software complexity – Simple is not easy

Recently I have seen a sentence “Simple solutions scale better, are easier to maintain and deliver value faster.”

In this post I will show some examples, where initial code which seems fine to implement, may not be relevant when application grows and requires thinking in terms of scalability, maintainability and readability. Some parts of code will require accepting different trade offs, broader perspective on what needs to be achieved and definietly – more abstraction layers and sophisticated approach.

The examples below are written in JavaScript, but I believe they may be understood by any software developer.
 

1. Imperative vs. Declarative Programming (describing logic)

The Illusion of Simplicity (Imperative): Imperative programming focuses on how to achieve a result by explicitly stating the steps. It can seem simple for basic tasks.

const numbers = [1, 2, 3, 4, 5];
const doubled = [];
for (let i = 0; i < numbers.length; i++) {
  doubled.push(numbers[i] * 2);
}
console.log(doubled); // [2, 4, 6, 8, 10]

The hidden complexity: As the logic becomes more intricate, imperative code can become verbose and harder to reason about. Managing state and side effects explicitly can lead to more opportunities for errors.

The path to simplicity: Declarative programming focuses on what the result should be, abstracting away the control flow. Higher-order functions in JavaScript enable a more declarative style.

const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(number => number * 2);
console.log(doubled); // [2, 4, 6, 8, 10]

The map function abstracts away the iteration process, making the code more concise and easier to understand the intent – to double each number in the array. While the map function itself has underlying complexity, it provides a simpler interface for the developer.

2. Reduce vs Map and Filter
This example will show that using more sophisticated code (which is more efficient) might cause a cognitive overwhelm. Let’s say we want to have an array of doubled numbers, only when the value is higher than 3. You may use 'reduce' for that it will loop through the array, multiply number if it is higher than 2, then push it to the output array.

const numbers = [1, 2, 3, 4, 5];
const doubledHigherThanTwo = numbers.reduce((acc, number) => {
  if (number > 2) {
    acc.push(number * 2);
  }
  return acc;
}, []);
console.log(doubledHigherThanTwo); // [6, 8, 10]

For this task you may also use filter and map combined, which seems more clear. It filters out numbers higher than 2, then makes multiplying on each element.

const numbers = [1, 2, 3, 4, 5];
const doubledHigherThanTwo =
  numbers
    .filter(number => number > 2)
    .map(number => number * 2);
console.log(doubledHigherThanTwo); // [6, 8, 10]

Both of the solutions are fine and using reduce may be more flexible in future, but you can use the second approach, when code readability or maintainability (debugging) is your priority.

3. Factory design pattern
Imagine you're building a system to manage different types of notifications (email, SMS, push). Creating a notification might involve setting up API keys, formatting the message, and potentially logging the creation.

class EmailNotification {
  constructor(recipient, subject, body, apiKey) {
    this.recipient = recipient;
    this.subject = subject;
    this.body = body;
    this.apiKey = apiKey;
    this.setupEmailService();
    this.formatMessage();
    this.logCreation();
  }

  setupEmailService() {
    console.log(`Setting up email service with API key: ${this.apiKey.substring(0, 5)}...`);
    // Imagine actual API client initialization here
  }

  formatMessage() {
    this.formattedBody = `Subject: ${this.subject}\n\n${this.body}`;
    console.log("Email message formatted.");
  }

  send() {
    console.log(`Sending email to ${this.recipient}:\n${this.formattedBody}`);
    // Imagine actual sending logic here
  }

  logCreation() {
    console.log(`Email notification created for ${this.recipient} at ${new Date().toLocaleTimeString()}.`);
  }
}

class SMSNotification {
  constructor(phoneNumber, message, accountSid, authToken) {
    this.phoneNumber = phoneNumber;
    this.message = message;
    this.accountSid = accountSid;
    this.authToken = authToken;
    this.setupSMSService();
    this.truncateMessage();
    this.logCreation();
  }

  setupSMSService() {
    console.log(`Setting up SMS service with SID: ${this.accountSid.substring(0, 5)}...`);
    // Imagine actual API client initialization here
  }

  truncateMessage() {
    this.truncatedMessage = this.message.substring(0, 140); // Basic truncation
    console.log("SMS message truncated (if necessary).");
  }

  send() {
    console.log(`Sending SMS to ${this.phoneNumber}: ${this.truncatedMessage}`);
    // Imagine actual sending logic here
  }

  logCreation() {
    console.log(`SMS notification created for ${this.phoneNumber} at ${new Date().toLocaleTimeString()}.`);
  }
}

// Client code creating notifications directly
const email = new EmailNotification(
  "user@example.com",
  "Important Update",
  "This is the content of the important update.",
  "YOUR_EMAIL_API_KEY_SECRET"
);
email.send();

const sms = new SMSNotification(
  "+1234567890",
  "Hey, check out the latest news!",
  "ACCOUNTSID12345",
  "AUTHTOKEN_SECRET"
);
sms.send();

Problems with this approach:

Repetitive Initialization: The client code is responsible for knowing which concrete class to instantiate and providing all the necessary initialization parameters.

Tight Coupling: The client code is directly coupled to the concrete EmailNotification and SMSNotification classes. If you add a new notification type, you'll need to modify the client code.

Scattered Logic: The complex initialization steps are within each notification class. If the initialization logic becomes more involved or shared across notification types, it can lead to duplication.

Now, let's refactor this using a Factory Pattern. We'll create a NotificationFactory to handle the object creation.

class EmailNotification {
  constructor(recipient, subject, body) {
    this.recipient = recipient;
    this.subject = subject;
    this.body = body;
    this.isServiceSetup = false;
    this.isMessageFormatted = false;
  }

  setupService(apiKey) {
    console.log(`Setting up email service with API key: ${apiKey.substring(0, 5)}...`);
    this.apiKey = apiKey;
    this.isServiceSetup = true;
  }

  formatMessage() {
    this.formattedBody = `Subject: ${this.subject}\n\n${this.body}`;
    console.log("Email message formatted.");
    this.isMessageFormatted = true;
  }

  send() {
    if (!this.isServiceSetup || !this.isMessageFormatted) {
      console.error("Email service not properly set up or message not formatted.");
      return;
    }
    console.log(`Sending email to ${this.recipient}:\n${this.formattedBody}`);
    // Imagine actual sending logic here
  }

  logCreation() {
    console.log(`Email notification created for ${this.recipient} at ${new Date().toLocaleTimeString()}.`);
  }
}

class SMSNotification {
  constructor(phoneNumber, message) {
    this.phoneNumber = phoneNumber;
    this.message = message;
    this.isServiceSetup = false;
    this.isMessageTruncated = false;
  }

  setupService(accountSid, authToken) {
    console.log(`Setting up SMS service with SID: ${accountSid.substring(0, 5)}...`);
    this.accountSid = accountSid;
    this.authToken = authToken;
    this.isServiceSetup = true;
  }

  truncateMessage() {
    this.truncatedMessage = this.message.substring(0, 140); // Basic truncation
    console.log("SMS message truncated (if necessary).");
    this.isMessageTruncated = true;
  }

  send() {
    if (!this.isServiceSetup || !this.isMessageTruncated) {
      console.error("SMS service not properly set up or message not truncated.");
      return;
    }
    console.log(`Sending SMS to ${this.phoneNumber}: ${this.truncatedMessage}`);
    // Imagine actual sending logic here
  }

  logCreation() {
    console.log(`SMS notification created for ${this.phoneNumber} at ${new Date().toLocaleTimeString()}.`);
  }
}

class NotificationFactory {
  constructor(emailApiKey, smsAccountSid, smsAuthToken) {
    this.emailApiKey = emailApiKey;
    this.smsAccountSid = smsAccountSid;
    this.smsAuthToken = smsAuthToken;
  }

  createNotification(type, ...args) {
    switch (type) {
      case 'email':
        const emailNotification = new EmailNotification(args[0], args[1], args[2]);
        emailNotification.setupService(this.emailApiKey);
        emailNotification.formatMessage();
        emailNotification.logCreation();
        return emailNotification;
      case 'sms':
        const smsNotification = new SMSNotification(args[0], args[1]);
        smsNotification.setupService(this.smsAccountSid, this.smsAuthToken);
        smsNotification.truncateMessage();
        smsNotification.logCreation();
        return smsNotification;
      default:
        throw new Error(`Unknown notification type: ${type}`);
    }
  }
}

// Client code using the factory
const notificationFactory = new NotificationFactory(
  "YOUR_EMAIL_API_KEY_SECRET",
  "ACCOUNTSID12345",
  "AUTHTOKEN_SECRET"
);

const emailNotification = notificationFactory.createNotification(
  'email',
  "user@example.com",
  "Important Update",
  "This is the content of the important update."
);
emailNotification.send();

const smsNotification = notificationFactory.createNotification(
  'sms',
  "+1234567890",
  "Hey, check out the latest news!"
);
smsNotification.send();

Benefits of using the Factory Pattern here:

Centralized Initialization: The complex initialization logic (setting up services, formatting/truncating messages, logging) is now handled within the NotificationFactory. The client code doesn't need to know the specifics of how each notification type is set up.

Decoupling: The client code interacts with the NotificationFactory interface, not the concrete notification classes directly. This makes it easier to add new notification types in the future without modifying the client code. You would simply extend the factory.

Simplified Client Code: The client code for creating notifications becomes much cleaner and more focused on providing the necessary data (recipient, subject, body, phone number, message).

Improved Maintainability: Changes to the initialization process for a specific notification type are isolated within the factory, making the system easier to maintain and debug.

In this example, the NotificationFactory encapsulates the complex steps involved in creating and initializing different notification objects, providing a cleaner and more maintainable way to manage object creation.

 
SUMMARY

These examples illustrate that what appears simple on the surface can often lead to significant complexity as the application scales or the requirements become more intricate. Achieving true simplicity often involves adopting more sophisticated patterns, architectures and language features that abstract away underlying complexities, leading to more maintainable, readable and robust code in the long run. "Simple is not easy" because it requires careful design, thoughtful abstraction, and often, embracing more powerful but initially seemingly more complex tools and paradigms.