Separation of Concerns Explained with Java Examples

Introduction

This tutorial explains Separation of Concerns (SoC), one of the oldest and most widely applied principles in software design, with a Java example. We will start with what a "concern" is and where the principle comes from. We will then take a payroll import method that mixes four concerns, and separate them into four small classes, comparing the output of both. Next, we will see how the same principle shapes layered applications and how cross-cutting concerns such as logging are handled. Lastly, we will compare SoC with the Single Responsibility Principle, and look at the ways it can be overdone.

What is Separation of Concerns

A concern is any distinct part of a problem that a program has to deal with: reading an input format, validating data, applying business rules, storing data, presenting results, logging, security. Separation of Concerns says that each concern should be handled in its own part of the program, with as little overlap as possible, so that each part can be understood and changed on its own.

Edsger W. Dijkstra used the phrase in his 1974 essay On the role of scientific thought, describing it as "the only available technique for effective ordering of one's thoughts": you study one aspect of a problem in isolation, knowing that it is only one aspect. In code, separating concerns lets a reader understand the tax rules without reading the CSV parsing, and lets a developer change the CSV parsing without risking the tax rules.

What mixed concerns look like

Concerns are usually mixed one line at a time. A method that starts as "read the file and print a total" soon also skips bad lines, applies tax and formats a report. The diagram shows such a method with each line coloured by its concern, next to the same work split into four classes.
Separation of concerns - a payroll import method with parsing, validation, tax rules and output interleaved, versus one class per concern

Example - separating a payroll import

The code below imports a small CSV of employee names and gross pay, skips invalid lines, applies a 20% tax and prints net pay with a total. It does it twice: once in a single method, and once with one class per concern.
Java code of a payroll import with mixed concerns and with separated concerns
//SeparationOfConcerns.java
import java.util.ArrayList;
import java.util.List;

public class SeparationOfConcerns {

  static final String CSV = """
      Tom Jones,5200
      Harry Major,4100
      ,3000
      Ethan Hardy,-50
      Nancy Smith,6300
      """;

  // ---------- Mixed: every concern in one method ----------
  static void importPayrollMixed(String csv) {
    double total = 0;
    for (String line : csv.strip().split("\n")) {
      String[] f = line.split(",");
      if (f[0].isBlank()) continue;
      double pay = Double.parseDouble(f[1]);
      if (pay < 0) continue;
      double tax = pay * 0.2;
      total += pay - tax;
      System.out.printf("%-12s %9.2f%n", f[0], pay - tax);
    }
    System.out.printf("%-12s %9.2f%n", "TOTAL", total);
  }

  // ---------- Separated: one class per concern ----------

  // Validation: what a valid entry is
  record PayrollEntry(String name, double grossPay) {
    PayrollEntry {
      if (name.isBlank()) throw new IllegalArgumentException("missing name");
      if (grossPay < 0) throw new IllegalArgumentException("negative pay");
    }
  }

  // Input format: CSV text to entries
  static class CsvPayrollParser {
    final List<String> rejected = new ArrayList<>();

    List<PayrollEntry> parse(String csv) {
      List<PayrollEntry> entries = new ArrayList<>();
      for (String line : csv.strip().split("\n")) {
        String[] f = line.split(",");
        try {
          entries.add(new PayrollEntry(f[0].strip(), Double.parseDouble(f[1].strip())));
        } catch (IllegalArgumentException e) {
          rejected.add(line + " (" + e.getMessage() + ")");
        }
      }
      return entries;
    }
  }

  // Business rule: tax and net pay
  static class NetPayCalculator {
    private final double taxRate;
    NetPayCalculator(double taxRate) { this.taxRate = taxRate; }
    double netPay(PayrollEntry e) { return e.grossPay() * (1 - taxRate); }
  }

  // Output: how results are shown
  static class PayrollReport {
    String render(List<PayrollEntry> entries, NetPayCalculator calculator) {
      StringBuilder out = new StringBuilder();
      double total = 0;
      for (PayrollEntry e : entries) {
        double net = calculator.netPay(e);
        total += net;
        out.append(String.format("%-12s %9.2f%n", e.name(), net));
      }
      return out.append(String.format("%-12s %9.2f%n", "TOTAL", total)).toString();
    }
  }

  public static void main(String[] args) {
    System.out.println("Mixed version:");
    importPayrollMixed(CSV);

    System.out.println("Separated version:");
    CsvPayrollParser parser = new CsvPayrollParser();
    List<PayrollEntry> entries = parser.parse(CSV);
    System.out.print(new PayrollReport().render(entries, new NetPayCalculator(0.20)));
    System.out.println("Rejected: " + parser.rejected);
  }
}
 OUTPUT of the above code
Mixed version:
Tom Jones      4160.00
Harry Major    3280.00
Nancy Smith    5040.00
TOTAL         12480.00
Separated version:
Tom Jones      4160.00
Harry Major    3280.00
Nancy Smith    5040.00
TOTAL         12480.00
Rejected: [,3000 (missing name), Ethan Hardy,-50 (negative pay)]

Explanation of the code

  • Both versions print the same net pay figures, but the mixed version silently drops the two bad lines. Reporting them would mean adding a fifth concern to the same loop.
  • PayrollEntry holds the validation rules. Because its constructor rejects invalid data, the calculator and the report never have to check for blank names or negative pay.
  • CsvPayrollParser only knows the input format. Supporting a JSON file means writing a JsonPayrollParser that returns the same List<PayrollEntry>; nothing else changes.
  • NetPayCalculator only knows the tax rule, and receives the rate from outside. A different rate, or a progressive tax, changes this class alone, and it can be unit-tested with a single PayrollEntry.
  • PayrollReport only knows the layout. It returns a String instead of printing, so the caller decides where the report goes - the console, a file or an HTTP response.
  • main() is the only place where the four parts meet. It reads like a description of the process: parse, render, show what was rejected.

Separation of concerns in a whole application

The same principle, applied at the level of a whole application, gives the familiar layered architecture. Each layer handles one concern - talking to users, running use cases, enforcing business rules, storing data - and talks only to the layer below it.
Separation of concerns in a layered Java application - presentation, application, domain and persistence layers with cross-cutting concerns
You meet SoC at every scale -
  • Methods and classes: one task per method, one responsibility per class.
  • Packages and modules: payroll.domain, payroll.web and payroll.persistence, with Java modulesClick to read: Java modules to enforce which may use which.
  • MVC: model (data and rules), view (presentation) and controller (input handling).
  • The web itself: HTML for structure, CSS for appearance and JavaScript for behaviour.
  • Services: separate deployable services for billing, payroll and notifications.

Cross-cutting concerns

Some concerns cannot live in one layer, because every layer needs them: logging, security checks, transactions, metrics and caching. These are called cross-cutting concerns. Written by hand, they get copied into every method - a log line at the start, a permission check, a try-finally for timing - and the method's real purpose is buried.

The usual ways to keep them separate are -

SoC and the Single Responsibility Principle

The two are closely related, but work at different levels. The Single Responsibility PrincipleClick to read: Single Responsibility Principle is about classes, and defines a responsibility as a reason to change. Separation of Concerns is a general principle that applies at every level, from a method to a system of services. A class that follows SRP is an example of SoC; SoC also tells you how to arrange packages, layers and services.

Overdoing it

  • Layers that only pass data through: a controller that calls a service that calls a repository, where the service adds nothing, is ceremony. Small applications can merge layers.
  • Splitting what changes together: if two classes always change in the same commit, they may be one concern split in two. Separate along lines of independent change.
  • Too many tiny pieces: a reader who has to open ten files to follow one simple operation has a different problem. Keep it simple - see DRY, KISS and YAGNIClick to read tutorial: DRY, KISS and YAGNI Principles with Java Examples.

Summary

In this tutorial we first saw what a concern is and how Dijkstra described Separation of Concerns. Next we looked at how concerns get mixed, one line at a time. We then split a payroll import into a validated record, a parser, a calculator and a report, and saw that each future change touches one class. We then looked at SoC in layered applications and at every scale from methods to services. We then saw how decorators, filters and AOP keep cross-cutting concerns such as logging apart. Lastly, we compared SoC with the Single Responsibility Principle and listed the ways the principle can be overdone.