Skip to main content

Separation of Concerns

Few things are more frustrating than making a small change and watching it ripple through half the codebase.

Separation of concerns means developing intentionally so that changing one part of the system does not require changes in other parts of the system.

Another aspect of separation of concerns is that:

  • Similar logic lives together.
  • Unrelated logic lives apart.

We separate logic so that it is grouped intentionally and organized by responsibility.

For example, a well-designed system keeps database logic separate from UI logic. You should be able to change the UI without affecting the database, or swap databases without rewriting the UI.

Separation of concerns is something of an extension of the DRY principle. Where DRY focuses on eliminating duplication of information and logic, separation of concerns focuses on avoiding duplication of intent across layers.

What This Looks Like

In structured architecture patterns (such as MVVM), responsibilities are clearly defined.

We separate:

  • Data access
  • Data caching
  • Business rules
  • UI logic

The specific structure may vary by architecture pattern, but the purpose is consistent: each piece of code should have a clear home based on what it does. When we add a new data source, we have clear direction for where to write data access logic, validation rules, and UI code.

When concerns are properly separated:

  • It is obvious where logic belongs
  • It is easy to find and modify behavior
  • Changes stay localized

Coupling

One of the main goals of separation of concerns is to reduce tight coupling between parts of the system.

Coupling describes how much one part of a system depends on another. Some coupling is unavoidable, because software components have to work together somehow. The goal is not to remove coupling entirely. The goal is to be intentional in design so that individual parts of the system do not depend on each other more than necessary.

We commonly use the terms tight coupling and loose coupling when talking about software:

  • Tight coupling means a change in one part tends to force a change in another.
  • Loose coupling means parts of the system still work together, but implementation details can change more independently.

Tight coupling often results in some kind of duplication of logic. The Pragmatic Programmer notes:

Duplicate code is a symptom of structural problems.

With tight coupling, multiple components begin to understand and implement the same knowledge. In a tightly coupled system, changing one of these components has a high chance of breaking the other components that implement the same knowledge. The worst part is, the bug you just introduced is often not immediately obvious.

Limiting Coupling

One of the simplest ways to limit coupling is to keep each concern in one clear place. If business rules, validation, or derived values are scattered across multiple layers, parts of the system start to depend on each other in fragile ways.

In practice, that means other parts of the system should depend on what a component does, not on how it is implemented internally. It also means avoiding duplicate fields or mirrored state when one value can be derived from another.

One warning sign of tight coupling is when a small change requires coordinated edits across many places:

  • a UI that knows too much about database structure
  • duplicated business rules in multiple services
  • child records that mirror parent record fields
  • helper methods that expose internal details instead of protecting them

These are all signs that the cost of change is starting to rise.

Examples

Let's take a look at some examples of applying separation of concerns in action.

Example: Caller Depends on Internal Details

In a simple object-oriented example, code that depends on a public method like project.markExpedited() is usually less coupled than code that directly updates several internal project fields. The method creates a stable boundary. The caller only needs to know what the component does, not how it works internally.

❌ Tight Coupling

Here, ProjectController needs to understand how Project stores its expedited state. If the internal structure of Project changes, ProjectController likely has to change too.

class Project {
isExpedited = false;
expeditedBy = "";
expeditedDate = "";
}

class ProjectController {
markExpedited(project: Project, user: string, date: string) {
// The caller must know which fields to update.
project.isExpedited = true;
project.expeditedBy = user;
project.expeditedDate = date;
}
}

✅ Loose Coupling

We can write this to move the implementation details of markExpedited() into the data model. After doing this, ProjectController does not need to know how Project works internally. It only needs to know that Project can be marked as expedited.

class Project {
private isExpedited = false;
private expeditedBy = "";
private expeditedDate = "";

markExpedited(user: string, date: string) {
// The object manages its own internal state.
this.isExpedited = true;
this.expeditedBy = user;
this.expeditedDate = date;
}
}

class ProjectController {
markExpedited(project: Project, user: string, date: string) {
// The caller only needs to know the public method.
project.markExpedited(user, date);
}
}

Example: Business Logic Inside the UI

One of the most common violations of separation of concerns is placing business rules directly inside UI code.

It often starts small. A quick validation. A conditional check. A rule that “only applies here.” Over time, those rules spread across screens and components.

UI code should only care about presenting data to the user, not about how the system works.

When business logic lives in the UI:

  • Rules are harder to find
  • Changes require searching through presentation code
  • The same rule may be implemented slightly differently in multiple places

Suppose users can submit an application only if:

  • They are at least 18 years old
  • Their account is verified

❌ Business Rules Inside the UI

// we show an "elevated" style material button that allows the user to submit the application
ElevatedButton(
onPressed: () {
// age and account verification check
if (user.age >= 18 && user.isVerified) {
submitApplication(user);
} else {
showError("You are not eligible to apply.");
}
},
child: const Text("Submit"),
);

This works, but the UI now contains business rules:

  • Minimum age requirement
  • Account verification requirement

If the rule changes (for example, the minimum age becomes 21), you must search through UI code to update it. If the same rule exists on multiple screens, it must be updated everywhere. Even more importantly, if there are other screens that allow interacting with applications (for example, updating the details of an application), there is no way to share these business rules.

✅ Business Rules in Use Cases, Accessed Through a View Model

The right way to handle this is to move the rule into a use case. The use case has a clear domain of responsibility in owning business logic rules, and the logic can now be accessed by any part of the program that needs it.

class ApplicationUseCases {
bool canSubmitApplication(User user) {
return user.age >= 18 && user.isVerified;
}
}

We then have the view model depend on the use case:

class ApplicationViewModel {
final ApplicationUseCases _useCases;

ApplicationViewModel(this._useCases);

bool canSubmit(User user) {
return _useCases.canSubmitApplication(user);
}
}

Now the UI depends only on the view model, not the use case:

ElevatedButton(
onPressed: () {
if (viewModel.canSubmit(user)) {
submitApplication(user);
} else {
showError("You are not eligible to apply.");
}
},
child: const Text("Submit"),
);

Now:

  • UI code stays focused on presentation
  • Business rules live in one place (the use case)
  • The view model is the UI’s only gateway to business logic
  • Rule changes are localized and easy to apply consistently

Technically, with this example, we introduced some coupling between the view model and the use case. But the tradeoff in coupling is offset by the fact that the logic has a clear home and the same logic can be implemented in any code that needs it.

Example: Duplication of Data Conversion Logic

Let's look at another example. Suppose we receive holiday data from an API.

In our code, we have a domain model that includes logic to convert the DTO (domain transfer object) from JSON into a class instance.

class Holiday {
final String name;
final DateTime date;

Holiday({
required this.name,
required this.date,
});

factory Holiday.fromJson(Map<String, dynamic> data) {
return Holiday(
name: data["name"],
date: DateTime.parse(data["date"]),
);
}
}

This is acceptable if we have decided that data conversion logic may live in data models (we have). The model knows how to construct itself from JSON.

❌ Tight Coupling and Duplication

Now suppose we add a domain object that stores a list of holidays fetched from the database.

// we assume Holiday exists in this codebase as shown above

class HolidaysList {
final List<Holiday> holidays;

HolidaysList(this.holidays);

factory HolidaysList.fromJsonArray(List<Map<String, dynamic>> dataArray) {
return HolidaysList(
dataArray.map((item) {
return Holiday(
name: item["name"],
date: DateTime.parse(item["date"]),
);
}).toList(),
);
}
}

This looks reasonable at first, but if we take a closer look we can see that:

  • The JSON field names are referenced here as well
  • The date parsing logic is created here as well

As a result, the knowledge of how a Holiday is constructed now lives in two places.

If the API changes and returns a different data format, both Holiday.fromJson and HolidaysList.fromJsonArray must change.

That is duplication of logic.

✅ Proper Separation of Concerns

The proper way to separate concerns is to reuse the existing converter logic from the Holiday class:

class HolidaysList {
final List<Holiday> holidays;

HolidaysList(this.holidays);

factory HolidaysList.fromJsonArray(List<Map<String, dynamic>> jsonArray) {
return HolidaysList(
jsonArray.map((json) => Holiday.fromJson(json)).toList(),
);
}
}

Now:

  • JSON parsing logic lives only in one place
  • We no longer have duplication of logic
  • If the API changes, only Holiday.fromJson must change

This keeps construction logic grouped together and prevents duplication of knowledge across domain objects.

Splitting Conversion Logic

It's also possible to avoid duplication but still violate separation of concerns by splitting deserialization logic into two places. There is no duplication of code, but the same kind of code lives in two separate places. We talk about this more in the next example.

Example: Splitting Logic Across Multiple Classes

Separation of concerns is not only about separating layers like UI and database. It is also about grouping related logic together so that responsibilities are clear and code stays well-organized.

When logic that serves a similar purpose is split across multiple places, it becomes harder to find and harder to change.

Let’s use a simple example:

We have a HolidaysList. We want to allow users to add more holidays, but we need to prevent:

  • Two holidays on the same date
  • Two holidays with the same name

Both of these are validation rules. The question is: where should they live?

❌ Splitting Validation Logic Across Layers

In this version, part of the validation lives in the domain model, and part of the validation lives in a use case:

class HolidaysList {
final List<Holiday> holidays;

HolidaysList(this.holidays);

void addHoliday(Holiday holiday) {
final hasSameDate = holidays.any(
(h) => h.date == holiday.date,
);

if (hasSameDate) {
throw Exception("A holiday already exists on this date.");
}

holidays.add(holiday);
}
}

class HolidaysListUseCases {
HolidaysList addHoliday(
HolidaysList list,
Holiday holiday,
) {
final hasSameName = list.holidays.any(
(h) => h.name == holiday.name,
);

if (hasSameName) {
throw Exception("A holiday with this name already exists.");
}

list.addHoliday(holiday);

return list;
}
}

This works, but the validation logic now exists in two different places.

In this situation, understanding the full rule for adding a holiday means you have to read both the model and the use case. If validation requirements change, you need to update multiple classes. This split also makes it easy to accidentally duplicate validation logic, since someone might note and add the "missing rule" in the use case without realizing it already exists in the model.

✅ Grouping Validation Logic in One Place

Instead, keep validation logic in one location:

class HolidaysList {
final List<Holiday> holidays;

HolidaysList(this.holidays);
}

class HolidaysListUseCases {
HolidaysList addHoliday(
HolidaysList list,
Holiday holiday,
) {
final hasSameDate = list.holidays.any(
(h) => h.date == holiday.date,
);

if (hasSameDate) {
throw Exception("A holiday already exists on this date.");
}

final hasSameName = list.holidays.any(
(h) => h.name == holiday.name,
);

if (hasSameName) {
throw Exception("A holiday with this name already exists.");
}

return HolidaysList(
[...list.holidays, holiday],
);
}
}

Now:

  • All validation rules are in one place
  • The full rule is easy to read and easy to modify

Grouping related logic together makes responsibilities predictable. When a rule changes, you know exactly where to look.

Note

It's important to note that the validation logic could live in either the model or the use case (although the use case is the ideal location). The key point is not where the logic lives, but that related logic is grouped together. The decision about where to write this logic may vary from one repository to another, and that's OK. When you choose a pattern, it's important to apply it consistently throughout a repository.

Framing Questions to Help You Apply Separation of Concerns

Separation of concerns requires deliberate design. It is something you apply continuously as the system evolves.

When writing or reviewing code, ask:

  • What is the responsibility of this class or file?
  • Is it handling more than one type of logic?
  • Is this logic grouped with similar logic?
  • Am I depending on internal details instead of public behavior?
  • If this rule changes, is it obvious where to make the update?
  • Would modifying this part of the system require changes elsewhere?
  • If this component changes, what else must change with it?

If responsibilities are unclear or scattered, concerns may not be properly separated.

Separation of concerns creates systems that are easier to understand and easier to change. Each component of your codebase should have a clear purpose. When logic is grouped intentionally and responsibilities are well defined, changes to business rules are simple and don't require large-scale code changes.