Engineering Atlas › Low-Level Design

Splitting concerns cleanly

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The problem

A nightly job keeps a build-artifact cache under its disk quota. The first version is one 80-line function: it walks the directory, reads each file's modification time, decides whether the file is too old, deletes it, keeps a running byte total, and prints a summary. It works. Then three requests arrive in one month: the platform team wants pinned artifacts to survive cleanup, finance wants the quota lowered from 500 GB to 300 GB with oldest files removed first, and the on-call team wants a dry-run mode that shows what would be deleted.

Every request touches the same function. Dry-run needs the decision without the deletion, which is impossible because both happen on the same line inside the loop, and testing anything requires a real directory with timestamps set by os.utime. The function has three concerns (policy, execution, reporting) and no seams between them.

Separate deciding from doing

The fix is to split the job along its concerns:

def plan_cleanup(files, now, max_age, max_bytes):   # policy: pure
    ...returns [(path, size, reason), ...]

def apply_plan(plan, fs):                             # execution: I/O through a port
    ...returns [(path, status), ...]

def render_report(plan, results):                     # presentation: pure
    ...returns a string

plan_cleanup takes plain data and returns plain data. It never touches the disk and never reads the clock; now is a parameter. Dry-run is now print(render_report(plan, [])). Pinning and quota ordering are changes to one pure function, tested with lists of tuples in microseconds. apply_plan is ten lines that loop and call fs.remove, and its tests need one fake that fails on a chosen path. Gary Bernhardt named this shape "functional core, imperative shell": push decisions into code with no side effects, and keep the code with side effects so simple it barely needs tests.

Three shapes of concern

Horizontal layers. Presentation, domain and data access are the classic split, described well in Fowler's note on presentation-domain-data layering. Each layer knows only the one beneath it. A web handler parses the request and renders the response; the domain decides; the repository loads and stores. The rule that matters is direction: the domain must not import the web framework or the SQL driver.

Vertical slices. Within a large application, features are also concerns. Billing, search and notifications change for different reasons and are owned by different people. Grouping code by feature (billing/handler.py, billing/rules.py, billing/repo.py) keeps a billing change inside one folder, where grouping by layer (handlers/, services/, repos/) spreads it across three. Most mature codebases do both: slices at the top level, layers inside each slice.

Cross-cutting concerns. Authentication, request IDs, logging, metrics, timeouts and transactions apply to almost every operation. Pasting them into each handler tangles them with business logic and guarantees that one handler forgets. They belong in a layer that wraps every handler: HTTP middleware in Go and Express, interceptors in gRPC, decorators in Python, aspects in Spring. The handler then contains only its own concern.

Separation of concerns is not separation of technologies

Splitting by file type is not splitting by concern. When React put markup and behaviour for one component in one file, critics called it a violation of separation of concerns. Pete Hunt's answer at JSConf EU 2013 was that HTML, CSS and JavaScript files separated technologies, not concerns; the component is the concern. Things that change together should live together, and things that change for different reasons should not.

Relation to SRP and coupling

The Single Responsibility Principle is separation of concerns applied to one class, with "concern" defined as "who asks for changes". Coupling and Cohesion gives you the measurements. Hexagonal architecture is separation of concerns at service scale: the domain in the centre, technology at the edges. Separation of concerns is the umbrella idea; the others are specific ways to apply it.

When not to separate

A 40-line script that runs once does not need a core, a shell and a presenter. Separation costs names, files and hops. A good trigger is the second change request that touches two concerns in one function, or the first test you cannot write without a real disk or network.

Reviewing agent-written code

Ask an agent to "add dry-run to the cleanup job" and the usual result is a dry_run flag threaded through the loop with if not dry_run: around each side effect. It passes the test it wrote, and it leaves the policy untestable without a filesystem. Ask instead for a planner that returns actions and an executor that performs them, then check three things in review: the planner has no imports of I/O modules and no calls to the clock, the executor has no business rules (no if size > ...), and the report is built from the plan and results rather than from print statements inside the loop.

The same shape elsewhere

Deciding separately from doing is the trust but verify shape: the plan is data, so a human, a test or a policy engine can inspect it before anything irreversible happens. Terraform's plan and apply, Kubernetes controllers that compute a diff before calling the API, database query planners that produce an EXPLAIN tree before execution, and agent frameworks that ask for approval of a tool call before running it all use the same split.

Down the stack

Layer 1The contract

Separation makes promises you can check. The policy module promises determinism: the same inputs produce the same plan, with no reads from disk, network, clock or randomness, so it can be called a million times in a property test. The execution module promises that it performs exactly the actions in the plan, in order, and reports the outcome of each, including partial failure; it does not add, drop or reorder actions. The presentation module promises that its output is a function of the plan and the results only. Each promise is a test you can write, and a violation (a hidden time.time() call, a filter inside the executor) is visible in review.

Layer 2The mechanism

Languages and frameworks give you tools to enforce the boundaries rather than hope for them. Go refuses to compile an import of a package under an internal/ directory from outside the tree rooted at that directory's parent. Java's module system only exposes packages listed in exports. Python has no enforcement, so teams use import-linter with a layers contract, and Java teams write ArchUnit tests such as "classes in ..domain.. must not depend on classes in ..web..".

Cross-cutting concerns rely on wrapping. Go's net/http middleware is a function func(http.Handler) http.Handler; a chain of five is five nested handlers, each calling the next one's ServeHTTP. Express keeps an array of (req, res, next) functions and advances an index each time next() is called. Spring implements @Transactional with a proxy object (a JDK dynamic proxy for interfaces, or a CGLIB-generated subclass) that opens the transaction and then calls your method. That mechanism has a well-known edge: a method calling another @Transactional method on this bypasses the proxy, so the inner annotation does nothing.

Layer 3The machine

Separation has a runtime cost, and it is usually small. Each layer or middleware is one more call; through an interface or function value it is an indirect call of about 1 to 3 ns when the branch predictor has seen it before, and the compiler cannot inline across it. A ten-middleware HTTP chain adds tens of nanoseconds to a request whose database query takes a millisecond.

The costs that matter come from copying and from round trips. Layers that each define their own struct for the same row (a database record, a domain object, an API DTO) copy fields at every boundary: for a 30-field object and 10,000 rows per page, that is 300,000 field copies and three sets of heap allocations, which shows up as GC work in Go and Java. A repository boundary that hides how data is loaded can turn one join into N queries at 0.3 to 1 ms each. And when a concern is separated into its own service, the boundary becomes serialisation plus a network round trip of roughly 0.2 to 0.5 ms inside one data centre, a million times more than a function call. Separate in code first; separate into processes only when deployment or scaling demands it.

Why it's built this way

  1. People can only reason about a small number of things at once, so a change is safe only if it can be understood locally.
  2. Concerns change at different rates and for different owners, so code that mixes them forces every change to be understood globally.
  3. Side effects are what make code hard to test and hard to preview, so pulling decisions away from effects yields a core that is cheap to test and a plan that can be inspected.
  4. Cross-cutting concerns would otherwise be copied into every operation, so they are applied once, by wrapping.
  5. Every boundary costs a call, a copy or a round trip, so you draw boundaries where change actually happens, not everywhere.

Diagram

flowchart LR
  FS0["disk listing (shell)"] --> D["files as data"]
  CLK["clock (shell)"] --> N["now"]
  D --> P["plan_cleanup (pure policy)"]
  N --> P
  P --> PL["plan: path, size, reason"]
  PL --> A["apply_plan (shell)"]
  A --> FS1["fs.remove"]
  A --> R["results"]
  PL --> V["render_report (pure)"]
  R --> V
  V --> OUT["summary text"]

Worked example

A CI platform keeps 420 GB of build artifacts on a 500 GB volume across 1.8 million files. The original cleanup function mixed policy, deletion and logging. It had no unit tests, only an integration test that created 200 files in a temp directory and took 9 seconds. Two incidents in six months came from policy bugs: one run deleted pinned release artifacts because the pin check was inside an else branch that the quota path skipped.

The refactor split the job three ways. The planner takes a list of (path, size, mtime, pinned) tuples and returns actions. Its test suite grew to 64 cases, including a randomised property test that asserts pinned files never appear in a plan, and runs in 40 ms. The executor is 14 lines; three tests with a fake filesystem cover success, a permission error mid-plan, and a file that vanished before removal. The report is built from the plan and results.

Dry-run became a flag in main that skips apply_plan. Before the quota drop to 300 GB, ops reviewed a dry-run plan of 1.1 million files and 210 GB and caught that a team's nightly snapshot directory was unpinned. Planning 1.8 million entries takes about 1.5 seconds and a few hundred MB for the tuples; the unlink calls take roughly 25 minutes, so the separation cost nothing that mattered.

Trade-offs

OptionStrengthsWeaknessesChoose when
One function does everythingShort; easy to read onceUntestable without real I/O; every change touches itThrowaway scripts
Functional core, imperative shellPure policy tests; plans are previewableMore names; data must be passed in explicitlyAny job with real rules and side effects
Horizontal layersClear dependency direction; swappable storageA feature change touches every layerApplications with one main domain
Vertical slices with layers insideFeature changes stay localShared code needs a deliberate homeLarge codebases with several owning teams
Separate services per concernIndependent deploys and scalingNetwork calls, partial failure, data copiesOnly when teams or load demand it

Pitfalls in production

Leadership lens

Check yourself

  1. Why does moving the clock read out of the cleanup policy make dry-run easier as well as testing?

    Show answer

    Once the policy is a pure function of its inputs, it can be called on its own to produce a plan without performing it, and the same call in a test gives the same answer every time.

  2. Your team groups code into controllers/, services/ and repositories/, and every feature change touches all three folders. Which kind of separation is missing?

    Show answer

    Vertical separation by feature. Group by feature first and keep the layers inside each feature folder, so a change to one feature stays in one place.

  3. Why is logging better applied as middleware than written into each handler?

    Show answer

    It is a cross-cutting concern with the same rules for every request, so applying it once guarantees coverage and keeps each handler about its own concern.

  4. When is it wrong to separate a concern into its own service?

    Show answer

    When it does not need its own deployment, scaling or team. You pay network latency and partial failure for a separation that a module boundary would give you for free.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →