Separation of Concerns & Coupling
Software stays easy to change only if each part has one clear job and knows as little as possible about the others. If you mix responsibilities together, every change has unexpected effects. If you keep them separate, you can understand, test, and change one thing without breaking five. Aim for high cohesion inside a unit and loose coupling between units.
Separation of concerns means a piece of code does one clear thing: handle HTTP, enforce a business rule, or talk to the database. It does not mix all three. Coupling is how much one part depends on the internals of another. You want high cohesion (related things together) and low coupling (few, narrow, explicit dependencies). This is what lets a system grow without becoming too complex to manage.
This is not design for its own sake. In our system it has direct effects. Business rules and security checks mixed into controllers and SQL are hard to test, easy to bypass, and easy to get subtly wrong. Clear layers, and a clear place for each rule, make the important logic visible, testable, and applied consistently.
Give each part one job
- DoSeparate responsibilities into clear layers: transport/API, application/business logic, and data access. Give each layer one concern.
- AlwaysKeep business rules and security decisions in the domain or application layer. Do not scatter them through controllers or inline SQL, so they stay testable and applied consistently.
- DoAim for high cohesion: things that change together live together; things that do not, stay apart.
- ConsiderIf a class or method is hard to name or describe in one sentence, it may be doing several jobs that should be split.
- Do notPut domain logic in the data access layer, or data access in the domain. Each leak makes both harder to change and test.
- Do notBuild god classes or controllers that handle parsing, validation, business rules, persistence, and formatting all at once.
[HttpPost] public IActionResult Approve(Guid id) {
var c = conn.QuerySingle("SELECT * FROM Customers WHERE Id=@id", new{id});
if (c.Risk == "High") return BadRequest(); // business rule
conn.Execute("UPDATE Customers SET Status='Approved' WHERE Id=@id", new{id});
return Ok();
}
Transport, business rule, and persistence are merged. You cannot unit-test the approval rule without HTTP and a database, and the same rule will drift the moment it is needed elsewhere.
[HttpPost] public IActionResult Approve(Guid id) =>
_onboarding.Approve(id, User); // controller: just transport
// application layer: the rule lives here, unit-testable in isolation
public Result Approve(Guid id, IPrincipal user) { /* rule + repo call */ }
The controller only adapts HTTP. The business rule is one testable method. Persistence sits behind the repository. Each can change without disturbing the others.
Keep coupling loose and explicit
- DoDepend on narrow, explicit interfaces, not on concrete internals. Then a change behind the interface does not spread outward (see Inversion of Control).
- DoMake dependencies flow one way. Inner layers (domain) know nothing about outer ones (web, DB). The outer layer depends on the inner one, never the reverse.
- DoLet modules talk through clear contracts. Do not reach into each other's data or share mutable state.
- ConsiderSplitting along bounded contexts or feature boundaries, so a change to one feature rarely touches another.
- Do notCreate circular dependencies or let modules share internal state. Both turn independent parts into one tangled unit.
- AvoidOver-engineering the other way. Needless layers, indirection, and abstraction harm clarity as much as tangling does.
Self-review checklist
- AskCan I describe what this unit does in one sentence, or is it doing several jobs?
- AskAre business rules and security checks somewhere testable, or buried in a controller or SQL string?
- AskIf I change this, how many other parts must change with it?
- AskDo dependencies flow one way, or have I created a cycle or a shared-state tangle?