Layers & Separation of Concerns
Every backend you will ever work on is split into layers. Not because a framework demanded it, but because the alternative stops being editable at a few thousand lines. This lesson is the vocabulary for everything after it.
- →"I used a controller / service / repository split, and here is what each layer is and isn't allowed to know."
- →"Entities never leave the service layer — the API speaks DTOs, and here's why that matters."
- →"Two features in my codebase break that rule. One is fine, one has outgrown it, and I can tell you which."
1. The problem layers solve
Imagine one file that receives the HTTP request, checks the password, decides whether the user is allowed, writes the SQL, formats the JSON, and sends the email. It works. Everyone starts here.
Then it stops working — not at runtime, but as a thing humans can change:
- You can't test any of it in isolation. Testing the password rule means starting a web server and a database.
- You can't change one thing safely. Swapping the email provider means editing a file that also contains your authorization logic.
- You can't read it. To answer "what happens when a task is created?" you read 900 lines and hold all of it in your head at once.
- Two people can't work on it. Every change touches the same file.
Separation of concerns is the idea that each unit of code should have one reason to change. If the HTTP format changes, exactly one layer should need editing. If the database changes, a different one. If the business rule changes, a third.
The two words that measure this are coupling (how much one piece depends on another) and cohesion (how much the things inside one piece belong together). You want low coupling between units and high cohesion inside them. Layering is the most common way to get there in a web backend.
2. The three layers
Almost every server-side web application — Spring, Rails, Django, ASP.NET, Express — lands on the same three responsibilities, whatever it calls them.
The rule that makes it work: dependency direction
Controllers depend on services. Services depend on repositories. Never the reverse. A repository must not know a controller exists; a service must not know whether it was called by HTTP, a scheduled job, or a test.
That one-way rule is what buys you the payoff. Because TodoService has no idea what
HTTP is, a test can construct it with fake repositories and assert on business rules in
milliseconds — no server, no database, no network.
When a lower layer's details leak upward — a controller catching a database exception type, or a JSON response accidentally exposing a column name — that's a leaky abstraction. The layer boundary exists, but it isn't holding.
The fourth thing: the DTO boundary
An entity is a class mapped to a database table. A DTO (Data Transfer Object) is a class shaped for the wire. Beginners return entities straight from controllers, and it always bites eventually:
- You leak fields you didn't mean to — a
Userentity has apasswordHashon it. - Your API shape is now your table shape. Renaming a column becomes a breaking API change.
- Lazy-loading blows up during serialisation, at a point in the request where the database session may already be closed.
So the rule is: entities stay behind the service; the outside world sees DTOs.
3. In Studily: one feature, three files
Here is the whole pattern in your own codebase. The to-do feature is the newest one, so it is the cleanest example of the shape you settled on.
The controller knows only HTTP
// src/main/java/com/rnave/studily/todo/TodoController.java
@RestController
@RequestMapping("/api/todos")
public class TodoController {
private final TodoService todoService;
public TodoController(TodoService todoService) {
this.todoService = todoService;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public TodoDto create(@Valid @RequestBody TodoRequest req) {
return todoService.create(req);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
todoService.delete(id);
}
}
Read what this file does not contain. No SQL. No ownership check. No transaction.
No decision of any kind. Its entire job is: map a URL and a verb to a method, validate the shape
of the incoming JSON (@Valid), pick the right status code, hand off.
That's about 60 lines for five endpoints. When a controller starts growing, it's almost always because a decision has crept in that belongs one layer down.
The service makes the decisions
// src/main/java/com/rnave/studily/todo/TodoService.java
@Service
public class TodoService {
@Transactional
public TodoDto update(Long id, TodoRequest req) {
Todo todo = requireOwned(id); // authorization
apply(todo, req); // business rules
return TodoDto.from(todoRepository.save(todo)); // entity -> DTO
}
@Transactional(readOnly = true)
public Todo requireOwned(Long id) {
return todoRepository.findByIdAndUserId(id, currentUser.id())
.orElseThrow(() -> new NotFoundException("Task not found"));
}
}
Four distinct responsibilities live here, and all four are things a controller must not do:
| Responsibility | In the code | Why it belongs here |
|---|---|---|
| Authorization | requireOwned(id) | Every path into a to-do must pass it, not just this route |
| Transaction boundary | @Transactional | A unit of work is a business concept, not an HTTP one |
| Business rules | apply(todo, req) | Trimming, defaults, the checklist diff |
| Mapping to the wire | TodoDto.from(...) | The entity stops here; the DTO goes out |
Notice @Transactional sits on the service. That's the layer where
"one unit of work" is meaningful: update the to-do and its checklist rows, or neither.
Put it on the controller and your transaction boundary is defined by your URL structure, which is
a coincidence, not a design.
The repository is an interface with no body
// src/main/java/com/rnave/studily/todo/TodoRepository.java
public interface TodoRepository extends JpaRepository<Todo, Long> {
List<Todo> findByUserId(Long userId);
Optional<Todo> findByIdAndUserId(Long id, Long userId);
List<Todo> findByUserIdAndCompletedAtIsNullAndDueAtBetweenOrderByDueAtAsc(
Long userId, Instant from, Instant to);
}
There is no implementation. Spring Data reads the method names and generates the SQL:
findByIdAndUserId becomes WHERE id = ? AND user_id = ?. That's a
framework convenience, but the layering point stands on its own — the repository's only job is
"get me rows", and it holds no opinion about what they mean.
Open src/main/java/com/rnave/studily/todo/ and read the three files in this order:
TodoController → TodoService → TodoRepository.
It takes four minutes and it is the single best preparation for the question
"walk me through your architecture."
4. Where your own code breaks the rule
This is the part that turns a memorised pattern into something you actually understand. Two
features in Studily do not have a service layer: notes and calendar. Both put
@Transactional directly on the controller.
That was a deliberate decision, and the interesting bit is that it has aged differently in the two cases.
Notes: still fine
NoteController is about 72 lines for three endpoints. The only business logic in the
entire feature is a trim(). A service class here would be three methods that forward
straight through to the repository — pure ceremony, more files to open, nothing gained.
Match the ceremony to the size of the thing. Layers are a tool for managing complexity. Applying them where there is no complexity is cargo-culting, and a good reviewer will respect a reasoned exception more than a reflexive rule.
Calendar: has outgrown it
CalendarController started the same way. It is now around 155 lines and contains
category resolution, recurrence expansion, and series-scoped update and delete:
// CalendarController — business logic that drifted into the wrong layer
@PutMapping("/events/{id}")
@Transactional
public CalendarEventDto updateEvent(@PathVariable Long id,
@RequestParam(defaultValue = "OCCURRENCE") SeriesScope scope,
@Valid @RequestBody CalendarEventRequest req) {
CalendarEvent event = eventRepository.findByIdAndUserId(id, currentUser.id())
.orElseThrow(() -> new NotFoundException("Event not found"));
EventCategory category = resolveCategory(req.categoryId());
for (CalendarEvent target : scopeOf(event, scope)) { // <-- fan-out over a whole series
...
}
}
And here is the sharp edge: the academic item side of the identical feature — repeating
assignments, with the exact same series-scope logic — lives in AcademicItemService.
So one behaviour is implemented at two different layers depending on which entity it acts on.
This is a real inconsistency in code you wrote, and it is a strength to raise yourself. Interviewers are not looking for a flawless codebase from a junior candidate. They are looking for someone who can evaluate their own work.
"I use controller / service / repository everywhere except two features, notes and calendar,
where I put the transaction on the controller because they were thin enough that a service would
have been three forwarding methods. Notes still is. Calendar isn't — it grew recurrence handling
and is now doing real work in the wrong layer, while the equivalent logic for assignments lives in
a service. So the same behaviour sits at two different layers, and extracting a
CalendarService is on my list."
5. Beyond three layers (what's next in the industry)
You will hear other architecture names in interviews. You don't need to have used them, but recognising them and knowing what problem they solve is worth a lot.
| Name | The core idea | When it pays |
|---|---|---|
| Layered (what you built) | Controller / service / repository, dependencies point downward | Almost always the right starting point |
| Hexagonal / ports & adapters | The domain sits in the middle and defines interfaces (“ports”); HTTP, the database, and email are interchangeable “adapters” plugged into them | When you genuinely might swap infrastructure, or want the domain testable with zero framework |
| Vertical slices | Organise folders by feature rather than by layer | Large teams — you already do this: your packages are todo, canvas, ics, not controllers, services |
| Microservices | Separate deployables communicating over the network | When separate teams need to deploy independently — an organisational fix, not a technical one |
Your packages are grouped by feature (com.rnave.studily.todo holds the controller,
service, repository, entity and DTOs together) rather than by layer. That's the vertical-slice
idea, and it's why adding the whole Canvas feature meant creating one new package instead of
touching four existing ones.
6. Say it out loud
"Walk me through the architecture of your project."
Answer out loud, from memory, in under 90 seconds. Then reveal and compare — not to copy the wording, but to check you hit the same beats.
"It's a Spring Boot backend and a React frontend that ship as a single deployable — the frontend builds into the backend's static resources, so there's one image and one origin.
On the backend I use a standard three-layer split, organised by feature rather than by
layer, so each package like todo or canvas holds its own controller,
service, repository and DTOs. Controllers handle only HTTP: routing, request validation,
status codes. Services own the business rules, the ownership checks and the transaction
boundary. Repositories are Spring Data interfaces that just fetch rows.
The important rule is that entities never leave the service layer — everything crossing the API boundary is a DTO record. That keeps the database schema from becoming my public API contract, and it means I can't accidentally serialise something like a password hash.
There are two deliberate exceptions where I skipped the service layer because the feature was genuinely trivial. One of them, the calendar, has since grown recurrence logic and I'd extract a service now — the same logic for assignments already lives in one, so I have the same behaviour at two different layers."
7. Module quiz
Eight questions. 80% or better means move on; below 50% means reread sections 2 and 3.
8. Take-aways
- 1Separation of concerns means one reason to change. Layers are how a web backend achieves it.
- 2Controller = HTTP, service = decisions, repository = rows. Dependencies point one way only.
- 3
@Transactionalbelongs on the service, because a unit of work is a business concept. - 4Entities stay behind the service; DTOs cross the wire. Otherwise your schema is your API contract.
- 5Notes skips the service layer and that's fine. Calendar does too and no longer is. Knowing the difference is the actual skill.
File 01 — Architecture overview and File 14 — Notes (which argues the thin-feature exception in full).