Skip to main content

Separation of Concerns

Introduction

Measure Distance Icon

Separation of concerns is the most basic tool for identifying which modules should exist and how they should interact. It is a way to manage complexity by breaking a program into distinct sections, each of which addresses a separate concern. This allows for easier maintenance and modification of the code.

“One class, one thing. One method, one thing.”

This common soundbite is a fun idea, but barely scratches the surface of the topic. Separation of concerns applies across all levels of software development, from individual functions to entire systems and the teams that build them. It is a fundamental principle of software design that helps to manage complexity and improve maintainability.

What we really mean by separation of concerns is that each module should be specialized for a single concern. This means that the module should encapsulate all the code necessary to address that concern, and nothing else.

This same concept appears in every industry that deals with complexity. In construction, you have different teams for plumbing, electrical, and framing. In medicine, you have different doctors for different specialties. In software, you have different modules for different concerns.

Separation of Concerns at Different Levels

How we approach separation of concerns depends on the level of abstraction we are working at. The principle applies at all levels, but the specifics of how we apply it will vary.

Separation of Functions

When we are working at the level of individual functions, our main concern should be to keep functions small and focused. Consider the following function that calculates the total cost of a shopping cart. It needs to lookup the price of each item, calculate the total, and apply any discounts.

fun calculateTotalCost(cart: List<Item>): Double {
// Sum the prices of all items in the cart
var total = cart.sumByDouble { getPrice(it) }

// Apply a 10% discount for items being purchased in bulk
cart.groupBy { it }.filter { it.value.size > 9 }.keys.forEach {
total -= getPrice(it.key) * it.value * 0.1
}
return total
}

Trying to do everything all in one place just makes this hard to understand. It is calculating the total cost, looking up prices, and applying discounts. We can improve this by splitting it into smaller functions:

fun calculateTotalCost(cart: List<Item>): Double {
val total = cart.sumByDouble { getPrice(it) }
val bulkItems = filterBulkItems(cart)
return total - bulkDiscount(bulkItems)
}

private fun filterBulkItems(cart: List<Item>): List<Item> {
return cart.groupBy { it }.filter { it.value.size > 9 }.keys
}

private fun applyBulkDiscount(bulkItems: List<Item>): Double {
return bulkItems.sumByDouble { getPrice(it) * 0.1 }
}

Each function does a meaningful task and is easier to understand. The calculateTotalCost function is now a high-level overview of the process, while the helper functions handle the details. It's much easier to see what's going on, which makes it easier to test and maintain, and makes bugs less likely.

Separation of Classes

It is at the class level that separation of concerns becomes most important. Classes are the building blocks of our software, and how we organize them has a huge impact on the maintainability of our code. We need to establish clear boundaries between classes and ensure that each class has a single responsibility.

Accidental vs Essential Complexity

The concepts of accidental and essential complexity were first introduced by Fred Brooks in his seminal paper "No Silver Bullet". Accidental complexity is complexity that arises from the implementation details of a system, while essential complexity is complexity that arises from the problem domain itself.

  • Essential Complexity: Complexity that is inherent in the problem being solved. It cannot be removed without changing the problem itself. This is the complexity that is directly related the problem domain. This sort of complexity is unavoidable and must be dealt with to solve the problem. When essential complexity changes it happens because of changing requirements.
  • Accidental Complexity: Complexity that arises from the implementation details of a system. It is not inherent in the problem being solved, but is introduced by the way the system is designed and implemented. This might include things like database access, logging, or error handling. This complexity must exist to solve the problem, but it should be isolated from the essential complexity because we have more freedom to change it and it is more likely to change. It might change due to new technologies, contracts with third parties, resource constraints, or many other reasons.

Keeping accidental and essential complexity separate is a key part of separation of concerns. We want to isolate the accidental complexity in our code so that it doesn't leak into the essential complexity.

Example

Consider the following ShoppingCart class:

class ShoppingCart {
private val cart = mutableListOf<Item>()

fun addItemToCart(item: Item): Double {
cart.add(item)

val connection = DriverManager.getConnection("jdbc:sqlite:./cart.db")
val statement = connection.createStatement()
statement.executeUpdate("INSERT INTO cart VALUES (${item.id})")
connection.commit()
connection.close()

return calculateTotalCost()
}
}

To summarize, the addItemToCart function is doing three things:

  • Adding an item to a cart
  • Writing the item to a database
  • Calculating the total cost of the cart and returning it

Separation of concerns is non-existent here. It mixes accidental complexity (writing to a database) with essential complexity (adding an item to a cart and calculating the total). It tightly ties us to a specific database implementation, making it hard to change later. It also makes the class harder to test, as we have to mock out the database connection in order to test any aspect of the function.

To improve this, let's split the data storage out into a separate class:

class ShoppingCart(private val cartRepository: CartRepository) {
private val cart = mutableListOf<Item>()

fun addItemToCart(item: Item): Double {
cart.add(item)
cartRepository.saveItem(item)
return calculateTotalCost()
}
}

The addItemToCart function is now much cleaner and easier to understand. It doesn't need to know about the database implementation details, making it easier to test. The CartRepository class is responsible for handling the database interactions, keeping the concerns separated. The specific details about which database is being used are now hidden inside the CartRepository class, making it easier to change later if needed.

There is still room for improvement, though. Why does the ShoppingCart need to know about the CartRepository at all? It's not its concern. Why does adding an item to the cart require us to recalculate the total? We can further improve the design by adding a change listener to the cart:

class ShoppingCart(private val listener: CartListener) {
private val cart = mutableListOf<Item>()

fun addItemToCart(item: Item) {
cart.add(item)
listener.onCartChanged()
}
}

Now the ShoppingCart class is only concerned with managing the cart itself. It doesn't need to know about the database or the total cost. The CartListener interface can be implemented by any class that needs to know when the cart changes, such as a CartRepository or a TotalCalculator.

Separation of Modules

Modules, in this context, refers to groups of classes that work together to solve a particular problem. They are a way of organizing our code into logical units that can be developed and maintained independently. Modules should be designed to be as self-contained as possible, with clear boundaries between them.

We want each module to have a single responsibility, and to be as decoupled from other modules as possible. This makes it easier to understand and maintain the code, as changes to one module are less likely to have unintended consequences in another.

Doing this well requires a good understanding of the problem domain and the requirements of the system, but is worth almost any amount of effort. It is the key to building maintainable, scalable software. Clearly defined modules are easier to build, easier to test, and easier to evolve over time.

Separation of Concerns in Organizations

Systems

Separation of Concerns comes up frequently in the context of microservices. Microservices are an architectural style that structures an application as a collection of small, loosely coupled services. Each service is responsible for a single concern, and communicates with other services over a network.

Even if you aren't using microservices, the same principles apply. You should be looking for opportunities to break your system into smaller, more manageable pieces. This makes it easier to reason about the system as a whole, and allows you to scale different parts independently.

Long Distance Icon

Teams

Clear separation of concerns is also important at the team level. Each team should have a single responsibility, and should be as self-contained as possible. This means that they should be able to make decisions independently of other teams, and should be able to deliver value to the business without relying on other teams.

Teams that have clear ownership over their area of responsibility are more productive and more engaged. They are able to move quickly and make decisions with confidence, knowing that they are working on the right things.

Look for Opportunities to Improve

When you are writing code, you should be on the lookout for places where concerns have been inappropriately mixed together. Try to develop a warning indicator the gives you a hint that some ideas don't belong together. When it trips, refactor as needed. It is generally better to wait until you see a potential spot for splitting things apart, rather than trying to anticipate where you might need to split things apart. Sometimes the division is obvious, but it is more common to recognize an issue as you are working with the code.

During the design process, you should be looking for uncertainties. If you aren't sure about something, it is good practice to separate it out. For example, if you are unsure about how to handle a particular edge case, you might want to put that logic in a separate function. If you aren't sure which database you should use or whether some service is going to be efficient enough, you should separate that out into a separate module. This makes it easier to change later if you need to. Isolating uncertainty is always a good idea when designing software.

Conclusion

Separation of concerns is the most important tool for creating modular, maintainable software. It is a way of managing complexity by breaking a program into distinct sections, each of which addresses a separate concern. This makes it easier to understand, test, and modify the code, and allows for more flexible and scalable systems.

It applies at all levels of software development, from individual functions to entire systems. At the function level, we want to keep functions small and focused. At the class level, we want to ensure that each class has a single responsibility. At the module level, we want to organize our code into logical units that can be developed and maintained independently. And at the team level, we want to ensure that each team has a single responsibility and is as self-contained as possible.

Image Credits