# Separation of Concerns

Keeping distinct responsibilities in distinct places, so a change to one concern touches one region of code.

**Rests on:** Single Responsibility

**Leads to:** Cohesion and Coupling

*Software Engineering · Software Architecture*


## Why learn this

This is the first idea in software architecture that makes every other one make sense. Without it, 'layered architecture' is just boxes on a slide, and 'hexagonal architecture' is an absurd tangle of arrows. With it, you can look at any system and ask: 'Where does each responsibility live, and if I change this one thing, how much of the code will I have to touch?' That question is the heart of maintainability, testability, and the 'ilities' you will meet later. This concept unlocks: Cohesion and Coupling, Layered Architecture, Ports and Adapters, Dependency Inversion, and eventually the whole map of how large systems are structured. Every AI system you will build — a chatbot, a recommendation engine, a model that runs on a phone — is software first, and how you separate its concerns decides whether changing one small thing costs you an afternoon or a month.


## The intuition

Imagine you have a notebook where you keep everything: your shopping list, your appointments, your phone numbers, your ideas for a project. At first it is fine — everything is in one place. But when you need to find your dentist's number, you have to flip through the whole book. And when you want to check if you already bought milk, you scan pages of appointments. Now imagine you start a new notebook for each kind of thing. Suddenly, finding the dentist is easy: you go to the phone numbers book. Changing your shopping list does not disturb your appointments. This is separation of concerns: keeping distinct responsibilities in distinct places, so that a change to one thing touches only one region. In software, a 'concern' is a responsibility the code has — showing data to a user, storing data, deciding what data means. When those are mixed together, a small change in one place forces you to touch many places, and you can never be sure you have not broken something else. When they are separated, you can change one part without fearing the others.


## An analogy

Think of a restaurant kitchen. The chef, the prep cook, and the dishwasher each have one job. The chef decides what goes on the plate; the prep cook chops vegetables; the dishwasher cleans dishes. If you want to change the menu, you talk to the chef — you do not retrain the dishwasher. If the dishwasher is slow, you do not rewrite the recipes. Each concern — cooking, prep, washing — lives in its own station, and the flow between them is a clear handoff: the prep cook passes chopped vegetables to the chef; the chef passes dirty plates to the dishwasher. Now push the analogy to its limit. What if the chef also had to wash every dish he used? He would be slower, and when a plate broke, he would have to leave the stove. What if the prep cook also decided what was on the menu? Then changing a recipe would mean changing the chopping list, and the two would always be tangled. The restaurant works because each station does one thing and the handoffs are simple. Software works the same way: a 'controller' receives a request and passes it to a 'service' that does the real work, which passes data to a 'repository' that stores it. Each one has one concern, and a change to the storage does not change the business rules. But the analogy breaks down: in a kitchen, the stations are physically separate, so the separation is enforced by the room. In software, there is no room — the separation is enforced only by the discipline of the developer. If you are lazy, you can write the storage code right inside the business logic, and nothing stops you. That is why this concept is a discipline, not a physical law.


## Definition

Separation of concerns is the practice of dividing a software system into distinct parts, each responsible for one distinct responsibility, so that a change to one concern touches only the part of the code that owns it.


## How it connects

You have not met any of these yet, but here is where you are going. This concept is the root of everything in software architecture. Its direct child is **Single Responsibility**, which says a module should have one reason to change — naming a module with an 'and' means it is doing two jobs. Its siblings are **Cohesion and Coupling**: separation of concerns is achieved when you arrange for high cohesion inside a part (everything in it is related) and loose coupling between parts (they depend on each other as little as possible). Its descendants are **Layered Architecture**, which arranges parts into layers with a strict dependency direction, and **Ports and Adapters** (also called Hexagonal Architecture), which puts the core business logic in the centre and makes every external thing — database, UI, API — an adapter that plugs into a port the core defines. **Dependency Inversion** is the mechanism that lets the core depend only on abstractions, not on concrete details. All of these are built on the simple idea you are learning now: keep different responsibilities in different places. Your library notes connect this to 'Database as Architectural Pillar' — the data store is a concern that outlives the code, so it deserves its own layer — and to 'The Ilities', the non-functional qualities like maintainability and testability that separation buys you.


## The mathematics

This concept does not rest on arithmetic, algebra, or any branch of mathematics beyond logic. The core operation is the comparison: does this change touch more than one region? That is a set-theoretic idea — a region of code is a set of lines, and a change to a concern maps to a set of lines that must change. If the sets overlap, the concerns are entangled. The mathematics of separation is really the mathematics of boundaries: how do you partition a system so that the number of connections across boundaries is small, and the number of connections within a boundary is large? That is a graph-theoretic idea, but you do not need to know that now. All you need is the ability to count: how many places does a change touch? If the answer is 'one', you have separation. If the answer is 'six scattered files', you do not. So the mathematics here is counting, and the operation is the count of affected places.

Let C be the set of concerns in a system, and let F be the set of files. A change to concern c touches the set of files T(c). Separation of concerns is the goal that for any two distinct concerns c₁ and c₂, the sets T(c₁) and T(c₂) are disjoint: T(c₁) ∩ T(c₂) = ∅. In words: a change to one concern never touches a file that a change to another concern would touch. That is the only formula you need, and it is really a definition, not an equation.


## A worked example

```
Suppose a small program has three files: one shows data to the user (UI), one stores data (storage), and one applies business rules (logic). You decide to change the storage format from a text file to a database.

Step 1: Change 1: the storage file 'storage.py' is edited to talk to the database.   # That is the natural place for the change.
Step 2: Change 2: the logic file 'logic.py' currently reads from the old text file directly, so it must be edited to call the new database functions.   # Because the logic was not separated from storage, it knows too much about the storage format.
Step 3: Change 3: the UI file 'ui.py' also reads the text file directly for a status display, so it too must be edited.   # UI leaking into storage means a storage change breaks the UI.

Result: Total: three distinct files changed for one logical change. With separation of concerns, the logic and UI would call a storage interface that does not change, so only 'storage.py' would change — one file. The count goes from three to one.
```


## From scratch, in Python

```python
# Before separation: a single function that does everything
# This is a small example: a program that reads a list of numbers from a file,
# calculates the average, and prints it. Without separation, all concerns are
# mixed: file reading, calculation, and printing are all in one place.

# The building blocks we need:
# - a function: a named block of code that can be reused. Defined with 'def'.
#   It takes inputs in parentheses and returns a value with 'return'.
# - a list: an ordered collection of values, written in square brackets.
# - a loop: repeats a block of code. 'for x in collection' goes through each item.
# - a dictionary: a mapping from keys to values, written in curly braces.
#   We will use it to pass several related values around as one thing.

import math

def process_file(filename):
    # Read all numbers from the file
    f = open(filename, 'r')
    numbers = []
    for line in f:
        numbers.append(float(line.strip()))
    f.close()

    # Calculate the average
    total = 0
    for num in numbers:
        total = total + num
    average = total / len(numbers)

    # Print the result
    print('Average:', average)

# To use this, you would have a file called 'data.txt' with one number per line.
# process_file('data.txt')

# Problems: if you want to change the output format (e.g., to JSON), you must
# edit the same function. If you want to read from a database instead of a file,
# you must edit the same function. Each change touches one region, but that
# region is the whole function — the concerns are not separated.


# After separation: distinct functions for distinct concerns
# Now the file reading, the calculation, and the output are separate functions.
# Each function has one responsibility, and changing one does not affect the others.

# Concern 1: reading data from a file. Returns a list of floats.
def read_numbers_from_file(filename):
    f = open(filename, 'r')
    numbers = []
    for line in f:
        numbers.append(float(line.strip()))
    f.close()
    return numbers

# Concern 2: business logic — calculating the average. Takes a list, returns a float.
def calculate_average(numbers):
    total = 0
    for num in numbers:
        total = total + num
    return total / len(numbers)

# Concern 3: presenting the result to the user. Takes a number, prints it.
def print_average(average):
    print('Average:', average)

# A controller function that ties them together. This is the only place that
# knows how the concerns connect.
def main(filename):
    data = read_numbers_from_file(filename)
    avg = calculate_average(data)
    print_average(avg)

# main('data.txt')

# Now if you want to change the output format, you only edit print_average.
# If you want to read from a database, you only edit read_numbers_from_file.
# The calculation never changes. Each change touches one region only.
```


## What the code does

The 'before' version is one function that does everything: it opens a file, reads lines, converts them to numbers, sums them, divides, and prints. Every concern — input, processing, output — is interleaved. The 'after' version splits it into three functions, each with a single concern. `read_numbers_from_file` takes a filename and returns a list of numbers; it knows nothing about averages or printing. `calculate_average` takes a list of numbers and returns the average; it knows nothing about files or printing. `print_average` takes a number and prints it; it knows nothing about files or lists. The `main` function is the only place that sees all three and wires them together. A change to the storage format touches only `read_numbers_from_file`; a change to the output touches only `print_average`. The calculation is safe. This is the simplest possible demonstration of the concept: no classes, no frameworks, just functions, and the separation is visible in the function boundaries.


## Common misconceptions

**Separation means more files and more code** — If I split my code into more files and more functions, that's separation of concerns. The more files, the better.

Separation is about responsibility, not file count. You can have ten files that all mix the same three concerns, and one file that cleanly separates them into well-defined functions. Over-separation — splitting every line into its own function — produces indirection without benefit, as your library notes. The goal is that a change to one concern touches one region, not that the code is fragmented into tiny pieces. In fact: Separate by responsibility, not by file count. A module should have one reason to change, and that reason should be nameable in a sentence. If you can state what a file is responsible for in one sentence, you have separation; if you need an 'and', you have a problem.

**Separation is only for big systems** — My script is small, so I don't need to separate concerns. It's only important for huge enterprise systems.

Even a 50-line script that reads a file, computes, and prints will eventually need to change one of those three things. If they are mixed, changing the output format risks breaking the calculation. The cost of separation is tiny at small scale and the benefit is immediate when the first change comes. In fact: The principles scale down. For any code that will live longer than a day, separating input, processing, and output is worth doing. The habit of writing small single-purpose functions is what makes large systems maintainable; it starts with small scripts.

**Separation means nobody else's code can touch mine** — Separation of concerns is about making my code independent — I write my part and never have to talk to other developers.

Separation is about boundaries and interfaces, not isolation. The parts still need to communicate — the storage module must hand data to the logic module. The communication happens through defined interfaces, which is what makes the parts replaceable. If modules never talked to each other, you would have no system, only disconnected pieces. In fact: Separation is about defining the interfaces between parts so that each part can change internally without affecting others. The interfaces are the contract, and they are the point of the separation.

**The view is just a template; it can have any logic** — The UI code can contain some business rules, like calculating the total price with tax, because it's just a template and it's convenient.

This is the classic 'business logic leaking into UI' violation. If the tax calculation changes, you have to find it in every template or view that uses it. You will miss one, and a customer will be charged the wrong amount. The UI's concern is presentation, not computation. In fact: The UI should call a service or function to get the total. The UI displays what it is given; it does not compute business values. The calculation lives in a logic layer, in one place, so a change is a single edit.

**Separation is the same as modularity** — As long as my code is in modules (files), I have separation of concerns.

Modularity is a necessary condition but not sufficient. You can put the file reading, the calculation, and the printing all in one module and call it modular. But the concerns are still mixed, because a change to any one of them requires editing that module's internals. What matters is that each module has a single responsibility, not just that the code is split into files. In fact: Separation is about responsibility. Modularity is about file boundaries. The two often coincide, but not always. A module with one responsibility is both modular and separated; a module with three responsibilities is modular but not separated.

**Separation makes everything slower** — All these function calls and layers make the program slower. I can make it faster by putting everything in one hot loop.

For almost all applications, the overhead of a function call is negligible compared to the cost of a database query, a network round trip, or an LLM inference. The maintainability benefit dwarfs any micro-performance gain. Optimising for speed by entangling concerns is a trade you will regret when you have to change the code later. In fact: Write cleanly separated code first; profile to find real bottlenecks, and only optimise where measured. A 5% speed gain that makes the code unmaintainable is a bad trade.


## In short

Separation of concerns is the discipline of giving each responsibility its own home. When a change is needed, the blast radius is predictable: one region of code changes, and nothing else does. The test is simple: for any two concerns, the set of files that change for one should not overlap the set for the other. It is the foundation of every architectural pattern you will meet, and it is worth practising from your very first line of code.


## Where this is used

This is not a concept you 'use' in one library call; it is a discipline that shapes every AI system you will ever build. Here is how it shows up in each area, with the mechanism made explicit.

**Large language models (LLMs)** — When you build an application on top of an LLM, you separate the concern of 'talking to the model' from the concern of 'what the application does'. A developer does not write `model.generate(prompt)` inside a web handler and also store the result in a database in the same function. Instead, they have a `ModelClient` class whose only job is to take a prompt and return a completion, a `PromptBuilder` whose only job is to assemble the prompt from user input and retrieved context, and a `Database` module whose only job is to persist the conversation. When the model API changes (e.g., from GPT-3 to GPT-4, or from one vendor to another), only `ModelClient` changes. The other parts do not even know the model changed. In practice, a developer using the OpenAI Python SDK wraps the call in a function, and the rest of the code never imports the SDK directly. That separation is what lets you swap the model without rewriting the application.

**Small models on devices (TinyML, on-device)** — When a model runs on a phone or a Raspberry Pi, the concerns of model inference, sensor reading, and user interface are sharply separated. The inference code is a compiled binary or a lightweight interpreter that takes a tensor (a list of numbers) and returns a prediction. The sensor reading code collects raw audio or camera frames, normalises them, and packs them into the tensor. The UI code displays the result. A developer writing for TensorFlow Lite separates these into different modules: a `FeatureExtractor` that turns raw bytes into the input tensor, a `TFLiteInterpreter` that loads the model and runs it, and an `OutputMapper` that turns the output tensor into a human-readable label. When the model is updated, only the model file changes, not the feature extraction or the UI. When you change the microphone hardware, only the feature extractor changes. The separation is not optional here — the model file is too large and the hardware too varied to recompile the whole system for each change.

**Retrieval-Augmented Generation (RAG)** — RAG combines a retrieval system (search over a corpus) with a generation system (an LLM). The two are separate concerns. The retrieval part has its own modules: an `Embedder` that turns text into vectors, a `VectorStore` that stores and queries vectors, and a `Retriever` that combines them to find relevant passages. The generation part has a `Generator` that takes the retrieved passages and a user query and produces an answer. A developer building a RAG pipeline separates these into distinct classes. The `Retriever` does not know how the `Generator` works — it only returns a list of passages. The `Generator` does not know how the retrieval works — it only receives passages. If you change the embedding model, you change only the `Embedder`. If you change the LLM, you change only the `Generator`. This separation is what makes it possible to test each part independently: you can test retrieval with a mock generator, and generation with a mock retriever.

**Agentic systems (planning, tool calling, loops)** — An agentic system has many concerns: planning (deciding what to do next), tool calling (executing an action like querying a database or sending an email), memory (keeping track of what has happened), and user interaction. These must be separated or the agent becomes an unmaintainable tangle. A developer typically writes a `Planner` that uses the LLM to decide the next action, a `ToolRegistry` that maps tool names to functions, and an `AgentLoop` that runs the cycle: plan -> call tool -> observe -> plan again. The `Planner` does not know the implementation of the tools — it only sends a tool name and arguments. The `ToolRegistry` does not know the planning logic. If you add a new tool, you only add to the `ToolRegistry`. If you change the planning prompt, you only change the `Planner`. This separation is what allows the agent to be tested: you can test the planner with fake tools, and each tool in isolation. In practice, when using a framework like LangChain, a developer defines tools as separate functions with a `@tool` decorator, and the agent framework orchestrates them — the framework itself enforces separation of concerns between the model, the tools, and the memory.

**Everyday applied ML (recommendation, vision, forecasting, fraud, speech)** — Any ML project has at least these concerns: data loading, feature engineering, model training, model evaluation, and serving. A responsible developer separates them into different scripts or modules. For a fraud detection system, the `DataLoader` reads transactions from a database, the `FeatureEngineer` computes features like 'amount' and 'time since last transaction', the `ModelTrainer` fits a classification model to the features, the `Evaluator` computes precision and recall, and the `ServingAPI` exposes a prediction endpoint. The `ModelTrainer` never reads from the database directly — it takes a dataset. The `ServingAPI` never does feature engineering — it calls the `FeatureEngineer`. If the database schema changes, only the `DataLoader` changes. If the model algorithm changes, only the `ModelTrainer` changes. This is the difference between a throwaway notebook and a production system. In a notebook, every line is in one cell, and changing a single assumption means re-running everything. In production, each concern is a file, and a change to one is a single pull request.

**What a developer actually writes** — In practice, a developer does not think 'I am applying separation of concerns'. They write code with functions and classes, and they follow the rule: a function should do one thing, a class should have one responsibility. They might use a web framework like FastAPI, where the endpoint functions are thin — they parse the request, call a service function, and return a response. The service function contains the business logic, and the database access is in a separate repository class. They might use an ORM like SQLAlchemy, which separates the concern of database schema from the concern of business logic. They structure their project as a set of packages: `api/`, `services/`, `repositories/`, `models/`. The dependency direction is always one way: `api` depends on `services`, `services` depends on `repositories`, `repositories` depend on `models`. The 'dependency rule' from your library — outer layers may know inner layers, never the reverse — is exactly this concept applied to layers. 

**What breaks when it is done badly** — When separation is ignored, the blast radius of every change grows. A change to the database schema forces changes in the UI, because the UI reads the database fields directly. A change to the LLM vendor forces changes in the prompt logic, because the prompt is built inside the model call. A change to the feature engineering breaks the serving API, because the API does its own feature extraction. The worst case is what your library calls 'business logic leaking into UI or persistence': the UI displays a computed value that is actually a business rule, and a business rule changes, and you have to find every place in the UI and persistence layers that duplicates it. The result is a system where every change is risky, every test is brittle, and no one can predict what a one-line change will break. The fix is always the same: identify the distinct concerns, put each in its own module, and define clear interfaces between them.


---

Read this on the web: https://basavapurushottam.com/learn-ai/separation-of-concerns
