Foundations Lesson 01 ~20 min

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.

After this lesson you can say

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:

The concept

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.

HTTP world
Controller — the translator
Routes, HTTP verbs, status codes, request validation, serialising the response. No business rules.
Your domain
Service — the decision maker
Business rules, ownership checks, transaction boundaries, orchestration. Knows nothing about HTTP.
Storage
Repository — the librarian
Queries and persistence, one table's worth of concerns. No business rules either.

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.

Name to know

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:

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:

ResponsibilityIn the codeWhy it belongs here
AuthorizationrequireOwned(id)Every path into a to-do must pass it, not just this route
Transaction boundary@TransactionalA unit of work is a business concept, not an HTTP one
Business rulesapply(todo, req)Trimming, defaults, the checklist diff
Mapping to the wireTodoDto.from(...)The entity stops here; the DTO goes out
In your project

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.

Trace it yourself

Open src/main/java/com/rnave/studily/todo/ and read the three files in this order: TodoControllerTodoServiceTodoRepository. 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.

The principle

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.

Why this matters for you

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.

Say it like this

"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.

NameThe core ideaWhen 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
Worth noticing about your own repo

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

Interview prompt

"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

Go deeper in the written curriculum

File 01 — Architecture overview and File 14 — Notes (which argues the thin-feature exception in full).