Separation of concerns is one of the first topics that comes up when we want to move from writing code that simply works to writing code that is structured well. At first, I thought it only meant splitting code into smaller functions so it looked cleaner, was easier to reuse, and had less duplication. If a function looked too long, I would cut some lines out and move them into another function. If a piece of code looked too complex, I would do the same.
For years, I wrote code mostly based on instinct and personal preference without really asking whether there was a better way to think about structure. I did not seriously question where a responsibility should live, what a function should truly own, or whether splitting code actually improved the design. Then I started my journey in LUR to rethink the way I write code, and relearning separation of concerns became one of the first things I focused on. From that learning process, i made an article series ”What I’m Learning About Writing Better Structured Code.” and this is the first part.
Seeing the Problem
Working code is not the same as well-structured code. A program can work correctly for the current problem, current input, and current environment, yet still become difficult to change later. As the app grows, the code also needs to grow without creating unnecessary friction for the programmer or the system itself.
To see that difference more clearly, let’s look at a small Todo CLI app. At first glance, nothing seems obviously wrong with it. It runs, it accepts input, and it performs the features it promises. But if we look past the result and inspect the structure, we can start to see the real problem.
import Foundation
struct TodoItem {
var title: "String"
var isDone: Bool
}
var todos: [TodoItem] = []
func showMenu() {
print("")
print("=== Todo App ===")
print("1. Show all todos")
print("2. Add todo")
print("3. Toggle todo")
print("4. Remove todo")
print("5. Exit")
print("Choose:")
}
func showTodos() {
if todos.isEmpty {
print("No todos yet.")
return
}
for (index, todo) in todos.enumerated() {
let mark = todo.isDone ? "[x]" : "[ ]"
print("\(index + 1). \(mark) \(todo.title)")
}
}
func addTodo() {
print("Enter todo title: "\")"
if let input = readLine(), !input.trimmingCharacters(in: .whitespaces).isEmpty {
todos.append(TodoItem(title: "input, isDone: false))"
print("Todo added.")
} else {
print("Invalid title.")
}
}
func toggleTodo() {
showTodos()
if todos.isEmpty {
return
}
print("Enter todo number to toggle:")
if let input = readLine(), let number = Int(input) {
let index = number - 1
if index >= 0 && index < todos.count {
todos[index].isDone.toggle()
print("Todo updated.")
} else {
print("Invalid number.")
}
} else {
print("Please enter a valid number.")
}
}
func removeTodo() {
showTodos()
if todos.isEmpty {
return
}
print("Enter todo number to remove:")
if let input = readLine(), let number = Int(input) {
let index = number - 1
if index >= 0 && index < todos.count {
let removed = todos.remove(at: index)
print("Removed: \(removed.title)")
} else {
print("Invalid number.")
}
} else {
print("Please enter a valid number.")
}
}
func runApp() {
var isRunning = true
while isRunning {
showMenu()
let choice = readLine() ?? ""
switch choice {
case "1":
showTodos()
case "2":
addTodo()
case "3":
toggleTodo()
case "4":
removeTodo()
case "5":
isRunning = false
print("Goodbye.")
default:
print("Unknown option.")
}
}
}
runApp()
At first, this code looked fine to me because it worked. But the point of the lesson was not just to check whether the program ran. The real goal was to inspect the structure behind it.
If you cannot spot the problem yet, that is completely fine. I also did not see it clearly at first. In the next part, I will break down what separation of concerns actually means and the lens I started using to notice where the boundaries in this code were mixed.
Introduction to Separation of Concern
Separation of concerns simply means different parts of a program should handle different responsibilities. Instead of letting one place do everything, we try to give each part a clearer job. This becomes important as an app grows, because code that feels manageable at the beginning can quickly become hard to understand and harder to change.
When responsibilities are mixed together, one part of the program starts doing too many things at once. A small change in one feature can affect unrelated code. It becomes harder to tell where a piece of logic should live. Input and output, state changes, business rules, and app flow can all end up too close to each other. The code may still work, but its structure starts resisting change.
So the goal of separation of concerns is not to split everything into tiny pieces just for the sake of cleanliness. The real goal is to make responsibilities easier to understand, easier to change, and easier to reason about.
Supporting Concepts
To understand separation of concerns more clearly, we also need to look at several related concepts in software engineering. They help us see what separation of concerns is really trying to achieve, why mixed responsibilities become a problem, and what better boundaries can look like in code.
Responsibility ownership
Each part of the code should have a clearer job. For example, displaying things should mainly focus on displaying. State mutation should have a clearer owner. Input handling should not also become the place where all business rules live.
Cohesion
Things that belong together should stay together. A function can still do several small steps, but those steps should feel like one meaningful responsibility rather than unrelated tasks grouped in the same place.
Coupling
Different parts of the code should not know too much about each other. If everything can directly touch everything else, the code becomes harder to understand, change, and grow.
Modularity
Modularity means breaking a system into understandable pieces. This does not mean creating many files just for style. It means organizing code into parts that can be understood and changed more independently.
Abstraction
Abstraction means hiding detail behind a simpler and more meaningful idea. For example, toggleTodo(at:) is a more useful abstraction than repeating low-level mutation logic in many places.
Boundaries
Boundaries are the lines between different responsibilities.
- data
- state
- input/output
- business logic
- flow control
All of these concepts are useful as foundational knowledge for writing well-structured code, especially when trying to notice and apply separation of concerns. They are also closely connected. Responsibility ownership helps us ask which part of the code should own a job. Cohesion helps us check whether related work stays together. Coupling helps us see when different parts of the code depend on each other too much. Modularity and abstraction help organize those responsibilities into clearer pieces. Boundaries tie all of these ideas together by showing where one responsibility should stop and another should begin.
From my learning journey, the idea of boundaries was the most practical lens for noticing whether code was applying separation of concerns well. It helped me see how responsibilities were placed, where they were mixed, and where the structure started to become unclear. So in the next part, I will use boundaries as the main lens to inspect the legacy code above, while still supported by the other concepts.
Reading the Todo App Through Boundaries
Now that we have introduced boundaries as a lens, we can use them to inspect whether the Todo app mixes responsibilities that leads to poor separation of concern.
For example, in addTodo(), we can already see several different responsibilities living in the same place: user input, todo-related logic (business logic), state mutation, and output rendering. The function does not only add a todo. It also asks for input, validates it, updates shared state, and prints feedback to the user.
Without looking at the next code, try to copy the code and paste to your code editor then mark it based on the data, state, input/output, business logic and flow control.
From my perspective, i found this
-
TodoItem→ data model -
todos→ app state -
showMenu()→ output rendering -
showTodos()→ output rendering -
addTodo()→ user input + todo operations (business logic) + output rendering -
toggleTodo()→ user input + todo operations (business logic) + output rendering -
removeTodo()→ user input + todo operations (business logic) + output rendering -
runApp()→ app flow / menu loop + some user input
As you can see from the code above, several functions contain more than one kind of responsibility. That means the boundaries between responsibilities are blurred, and this is where mixed responsibilities start to appear. Because of that, the code does not follow separation of concerns very well.
The problem is not that the app has input, output, state, and logic. Of course it needs all of them. The problem is that too many of them are handled in the same place. When one function reads input, validates it, changes state, applies logic, and renders output at the same time, its responsibility becomes less clear.
The mental model
After noticing that, try asking yourself this question:
If this app changes, what kind of change should force this piece of code to change?
That question helps reveal the real point of separation of concerns. A well-structured piece of code should ideally change for one clear reason, not for many unrelated ones. If a single function has to change because of UI changes, input changes, validation changes, and business rule changes all at once, that is usually a sign that too many concerns are mixed together.
For example, imagine I want to change how input is taken. Maybe today the app uses readLine(), but later I want input to come from another interface. Functions like addTodo(), toggleTodo(), and removeTodo() would all need to change, because input handling is mixed directly into them. The same thing happens if I want to change how messages are shown to the user. Since those functions also print feedback directly, a display change would again force the same functions to change. Even a change in validation rules or todo behavior could affect those same places. One function ends up changing for multiple unrelated reasons, and that is the main sign that the boundaries are not clear enough.
So the point of separation of concerns is not just to split code into smaller pieces. The real point is to place responsibilities in clearer boundaries, so each part of the code has a more focused job and a more understandable reason to change.
From Noticing to Separating
After noticing code that does not apply separation of concerns well and understanding the pain it can create, the next question becomes: how should we start improving it?
This was the part that felt the most subjective to me. Even after seeing that responsibilities were mixed, I still did not know how to judge whether something should actually be separated. The hard part was that not everything inside a function deserves to become its own unit. Some parts are truly separate concerns, while others are just details of the same responsibility.
Then i found the most useful principle after looking to a lot of articles:
A separate concern is something that can change independently.
A detail is something that usually changes together with its parent responsibility.
In practical terms, this means I should not extract something just because it exists as a smaller step inside a function. I should ask whether that part could realistically need to change on its own.
For example, in addTodo(), input handling could change independently if the app later stops using readLine(). Validation could also change independently if the app starts enforcing stricter rules for todo titles. Output rendering could change independently if the way feedback is shown to the user changes. These are good signs that input handling, validation, and output rendering may deserve clearer boundaries instead of all living inside the same function.
But this judgment depends on the level we are looking at. A concern can be separate at one level, while still containing smaller details that should stay inside it. For example, in showTodos(), the line that chooses between "[x]" and "[ ]" is still part of the rendering job. It is a formatting detail inside rendering, not automatically a separate concern by itself. If the display style changes, that line would likely change together with the rest of the rendering logic.
That changed the question completely. I stopped asking only whether a function looked long or complicated. The better question became:
- Is this part likely to evolve on its own?
- Or is it just one small detail of the function’s main job?
From there, the most useful rule I learned was:
Extract only when the thing being extracted has its own meaningful reason to change.
Before this, I used to think about structure with a much simpler rule:
1 function = 1 purpose
But over time, I realized that the better version is this:
A function should have one cohesive responsibility at the level of abstraction it owns.
This idea was very important for me, because it helped me stop treating every small detail as a separate concern.
From Noticing to Separating, then Overthinking it
This new way of thinking also triggered a lot of overthinking for me. By this point, I would not be surprised if some readers are also starting to feel the same thing.
Most of that overthinking came from doubting my own judgment. In the example above, we are trying to think about how the code might need to change in the future. But once I started thinking in terms of future changes, the possibilities felt endless. What if the input changes? What if the output changes? What if validation becomes more complex? What if the app grows in another direction?
That is where my mind started branching too much. If every possible future scenario can be used as a reason to separate something, then it becomes easy to overthink the design and lose confidence in my own judgment.
Then I remembered something I once asked my Apple Developer Academy mentor, Gerson. His answer was
Your code is growing. Let it grow based on the need.
I understood that as a reminder that I do not need to predict the perfect architecture too early. Looking back, I think I had started to treat future change as something dangerous, as if needing to refactor later automatically meant I had failed (validating the overthinking above).
After that, I read more about this idea and realized the healthier way to think about it is much simpler:
- write the simplest code that solves the current problem
- let the code grow with real requirements
- refactor when the current shape starts resisting change
Refactoring later is not evil. It is a normal part of software development.
This connects with ideas like:
- make it work
- make it clear
- make it flexible
It also connects with the YAGNI principle, short for You Aren’t Gonna Need It. The core idea is that we should not build for imagined needs too early. If you want to read full explanation, here is the article .
A better approach is to solve the current problem clearly, then improve the structure when real change starts to demand it. That means a design is not automatically bad just because:
- it is simple
- it is unfinished
- it may need refactoring later
A design becomes bad when:
- the problem is still small
- but the code is already confusing
- even small changes already feel messy
- responsibility ownership is unclear right now
So the best way to think about it is not:
- right design
- wrong design
but rather:
- good enough for now
- starting to strain
- needs refactor soon
- too rigid and harmful
The Todo app above is currently in the “good enough for now, but future strain is visible” stage. If you want to go deeper into how to judge code like this, I wrote the next article, “Judging the Problem: Code Smells and Refactoring” which is Part 2 of my series, ”What I’m Learning About Writing Better Structured Code.”. It will be out soon, so if this topic interests you, feel free to follow me and stay tuned for the next part.
From the Writer
Hello, allow me to introduce myself. I’m Cakoko. We’ve reached the end of this article, and I sincerely thank you for taking the time to read it.
If you have any questions or feedback, feel free to reach out to me directly via email at cakoko.dev@gmail.com. I’m more than happy to hear your thoughts, whether they are about my English writing, the technical ideas in this article, or anything I may have misunderstood. Your feedback will help me grow.
I look forward to connecting with you in future articles. Btw, I’m a mobile developer, final year Computer Science student, and an Apple Developer Academy @ IL graduate. I’m also open to various opportunities such as collaborations, internships, or full-time positions. It would make me very happy to explore those possibilities.
Until next time, stay curious and keep learning.
Open for Feedback
This article is part of my personal learning journey. It may not be completely accurate or perfect, and that is okay. I’m sharing what I’ve learned so far in the hope that it can also help others who are exploring similar topics.
If you have any feedback, suggestions, or corrections, I would truly appreciate them. I’m always open to learning more and improving along the way.
Top comments (0)