What is Complexity?
Here, complexity refers to a combination of quantitative and qualitative complexity. Quantitative complexity adopts cyclomatic complexity or cognitive complexity. For our purposes, it’s sufficient to understand that a higher complexity is indicated simply by a higher number of conditional statements.
There isn’t a clear indicator for qualitative complexity, but for example, it could refer to research-based or experiential assertions such as the human inability to count beyond four. In both senses, the following code exemplifies high complexity:
class SubscriptionRegistrationService(
private val crmClient: CRMClient,
private val dbAccess: DatabaseAccess
) {
fun registerNewSubscription(customerId: String?, subscriptionType: String, durationMonths: Int, planPrice: Double): Result<String> {
var effectiveCustomerId = customerId
if (customerId == null || !crmClient.validateCustomerId(customerId)) {
effectiveCustomerId = crmClient.issueNewCustomerId()
crmClient.createCustomerProfile(effectiveCustomerId, newClientName())
}
if (!crmClient.isCustomerActive(effectiveCustomerId!!)) {
crmClient.activateCustomer(effectiveCustomerId)
}
if (!dbAccess.isValidSubscriptionType(subscriptionType)) {
return Result.failure(RegistrationError.InvalidSubscriptionType)
}
val validDurationMonths = if (durationMonths > 0) durationMonths else 12
val validPlanPrice = if (!dbAccess.isValidPlanPrice(subscriptionType, planPrice)) {
dbAccess.getDefaultPlanPrice(subscriptionType)
} else {
planPrice
}
if (dbAccess.doesSubscriptionExistForCustomer(effectiveCustomerId, subscriptionType)) {
return Result.failure(RegistrationError.SubscriptionAlreadyExists)
}
val isRegisteredSuccessfully = dbAccess.registerSubscription(effectiveCustomerId, subscriptionType, validDurationMonths, validPlanPrice)
return if (isRegisteredSuccessfully) {
Result.success("subscribed")
} else {
Result.failure(RegistrationError.RegistrationFailed)
}
}
private fun newClientName(): String {
val now = LocalDateTime.now()
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm")
val formattedDate = now.format(formatter)
return "New Client $formattedDate"
}
}
Assuming that all conditions need to be covered by the above code (100% MCC), the number of tests will literally grow exponentially.
Given that a constant sufficiency of tests is essential to guarantee quality, this problem is significant because it is practically impossible to keep up with this increase in test cases, even for very small projects.
Also needless to say, such code naturally has a serious impact on specification changes and defect fixes.
Contract Programming as a Divide and Conquer Strategy
In object-oriented programming, especially contract programming, the caller and the callee are regarded as the client and the supplier, respectively. The supplier demands strong preconditions from the client for outputting (postconditions). By adopting the idea of contract programming, programs become radically simplified.
class SubscriptionRegistrationService(
private val subscriptionService: SubscriptionService,
private val subscriptions: Subscriptions // equivalent to DatabaseAccess
) {
// this class method requires clients to satisfy preconditions through parameter types
fun registerNewSubscription(customer: Customer, subscription: Subscription): Result<Unit> = runCatching {
subscriptionService.assertUniequeSubscriptionFor(customer, subscription).getOrThrow()
subscriptions.registerSubscription(subscription).getOrThrow()
}
}
// this is a interface because it may need to access data stores
interface SubscriptionService {
fun assertUniequeSubscriptionFor(customer: Customer, subscription: Subscription): Result<Unit>
}
data class Customer(val cusdata class Customer(val customerId: String, val active: Boolean, val name: String) {
init {
// invariants of "Customer" entity
require(customerId.isNotBlank()) { "Customer ID cannot be blank." }
require(active) { "The customer must be active." }
require(name.isNotBlank()) { "Customer name cannot be blank." }
}
}
class Subscription(
val customerId: String,
val subscriptionType: String,
val durationMonths: Int,
val planPrice: Double
) {
companion object {
private val validSubscriptionTypes = setOf("Monthly", "Annual", "Trial")
}
init {
// invariants of "Subscription" entity
require(customerId.isNotBlank()) { "Customer ID cannot be blank." }
require(subscriptionType in validSubscriptionTypes) { "Invalid subscription type." }
require(durationMonths > 0) { "The subscription duration must be at least 1 month." }
require(planPrice > 0) { "The plan price must be a positive number." }
}
}
This is one implementation of divide and conquer (Divide et Impera). Tests can now be managed independently for each entity. The responsibilities of each entity have been clarified by introducing the concept of contracts, making it easier to respond to changes in the specification. Also, multiple entities embedded in procedures can now be re-used as class instances while retaining their invariants.
Locally, the number of test cases required to achieve 100% coverage remains the same, but the ability to reuse entities significantly reduces the number of test cases required across the application.
Is Structured Programming Ineffective for Divide and Conquer?
Here, for simplicity, we refer to the programming method of managing data and its processes separately as structured programming [Ivar 1992]. The complexity brought to software, in the scope of our discussion, is primarily the validation of values that primitive types like integers and strings can take in a logical domain.
In object-oriented programming, it is possible to encapsulate these values along with their validation before passing them from the client to the supplier. However, in structured programming, since there is no restriction on access to values, some method must guarantee that values are always validated (invariants of values).
The book by Bertrand [Bertrand 1997], which proposed contract programming, assumes a class-based statically typed language. However, in an event-driven language like JS, it might involve attaching a receiver to a primitive object and firing an event, for instance. This approach has the disadvantage of only confirming the validity at runtime, but it is effective when there is no means, either functionally or conventionally, to limit access to values, when changes to public library APIs are unwanted, or when object-oriented development is not enforced for other developers.
// Define a simple object that represents a book
let uncertainBook = {
title: "Effective JavaScript",
year: 2021,
author: "David Herman"
};
// Define a handler object that represents invariants.
// This will be called when the proxied object mutates.
const handler = {
get(target, property, receiver) {
console.log(`Accessing property ${property}`);
return Reflect.get(...arguments);
},
set(target, property, value) {
// Example validation: Ensure 'year' is a valid number
if (property === 'year' && (typeof value !== 'number' || value < 1900 || value > new Date().getFullYear())) {
throw new Error('Year must be a valid number between 1900 and the current year.');
}
// Example validation: Ensure 'title' and 'author' are non-empty strings
if ((property === 'title' || property === 'author') && (!value || typeof value !== 'string')) {
throw new Error(`${property.charAt(0).toUpperCase() + property.slice(1)} must be a non-empty string.`);
}
console.log(`Setting property ${property} to ${value}`);
return Reflect.set(...arguments);
}
};
// Wrap the book object with a Proxy to enforce the above defined behavior
const eligibleBook = new Proxy(uncertainBook, handler);
// Test the Proxy behavior
try {
eligibleBook.title = "JavaScript: The Good Parts"; // Should work
eligibleBook.year = 2008; // Should work
// eligibleBook.year = "2020"; // Should throw an error due to invalid type
// eligibleBook.author = ""; // Should throw an error due to empty string
} catch (e) {
console.error(e.message);
}
Handling Contract Violations
When user input causes a contract violation, the simplest scenario is, for example, in a REST API server where the logical error propagates through the call stack and eventually notifies the client built with React or Swift via an HTTP Response as an error code. This example is purely for illustration; in practice, such contract violations should be prevented by validating input values at the client or presentation layer, thus never occurring.
It’s simple if the direct client — your colleague, in other words — breaches a contract. Contract violations result in static errors at compile time, preventing incorrect code from inadvertently slipping through. In dynamically typed languages, contract violations are only detected when runtime errors occur during automated or manual testing.
If preconditions are made too strict or complex, the component becomes unusable by anyone. Developers might start looking for workarounds, so care is necessary.
Strong vs. Weak Preconditions
Strong preconditions demand stringent assumptions from the client. Typically,
it makes initializing objects as arguments for methods or functions more challenging.
Conversely, weak preconditions might simply require a primitive type String, for example.
While strong preconditions reduce internal complexity and improve quality for the supplier, they can render the component inaccessible to users in the worst cases, effectively rendering it non-functional.
Adopting weak preconditions often leads to an increase in implicit behavior, such as fallback processes for otherwise invalid values. In dynamically typed languages, all processing inherently assumes the most extensive weak preconditions due to the inability to enforce type constraints.
In scenarios requiring weak preconditions, it’s common practice to cluster necessary value validations at the beginning of a series of processes. This approach to programming based on weak preconditions is known as defensive programming.
The following is an example of a very weak precondition that simply requires two objects from the client.
function registerNewSubscription(customer, subscription) {
// guard clauses
if (!customer?.customerId?.trim()) throw new Error("Customer ID cannot be blank.");
if (!customer?.active) throw new Error("The customer must be active.");
if (!customer?.name?.trim()) throw new Error("Customer name cannot be blank.");
if (!subscription?.subscriptionType || !validSubscriptionTypes.includes(subscription.subscriptionType)) {
throw new Error("Invalid subscription type.");
}
if (!(subscription?.durationMonths > 0)) throw new Error("The subscription duration must be at least 1 month.");
if (!(subscription?.planPrice > 0)) throw new Error("The plan price must be a positive number.");
// original obligation
dbAccess.registerSubscription(
customer.customerId,
subscription.subscriptionType,
subscription.durationMonths,
subscription.planPrice
);
}
Unaddressed Considerations
- When to adopt defensive programming
- Immutability and contract programming
- Implementing contracts in functional style programming
- Integration of DDD and contract programming, and security