Updated Jun 22, 2026

The Service Layer, DTOs & Validation

By now your API works. Back in Phase 3 you wrote a BookController that took HTTP requests, and in Phase 5 you wired a BookRepository so those requests actually hit a database. If you followed along literally, your controller probably calls the repository directly — request comes in, controller saves a row, controller returns it. For three endpoints, that's fine. It's also a trap, and it's worth seeing why before the trap closes.

The mental model for this whole phase is separation of concerns: each piece of your app should have exactly one job, and should hand off to the next piece for everything else. A controller's one job is to speak HTTP. It should not also know how to validate a book, calculate a discount, enforce that ISBNs are unique, or manage a database transaction. The moment a controller does two of those things, it becomes the place every future change has to touch — and the place every bug hides. This phase introduces the three layers that fix that, plus the validation that guards the front door.

Why a service layer

📝 A well-structured Spring app has three layers, and a request flows through them in order:

  • Controller — speaks HTTP. Reads the request, calls down, shapes the response. Knows about status codes and JSON. Knows nothing about business rules.
  • Service — holds the business logic. "A book can't be published in the future." "Deleting a book archives it instead." This is where the actual decisions live, in plain @Service beans.
  • Repository — talks to the database. You met this in Phase 5; it knows rows and queries and nothing else.

Here's the shape of it:

flowchart TD
  Client[HTTP client] -->|JSON request| Controller
  Controller -->|calls| Service
  Service -->|calls| Repository
  Repository -->|SQL| DB[(Database)]
  DB -->|rows| Repository
  Repository -->|entities| Service
  Service -->|DTOs| Controller
  Controller -->|JSON response| Client

The rule that makes this work: each layer only talks to the one directly below it. Controllers never touch repositories. Services never touch HTTP. The dependency arrows point one direction.

💡 Why bother, when calling the repository from the controller "works"? Because the controller is the worst possible home for logic. It's hard to test (you need a fake HTTP request to exercise a price calculation), it's hard to reuse (a scheduled job can't send itself an HTTP request to run the same rule), and it quietly accumulates responsibilities until nobody can read it. A @Service is a plain bean you can call from a controller, a job, a message listener, or a test — directly, no HTTP required.

Let's move the logic out of the Phase 3 controller. Here's roughly the "everything in the controller" version we're leaving behind:

@RestController
@RequestMapping("/api/books")
public class BookController {

    private final BookRepository books;

    public BookController(BookRepository books) {
        this.books = books;
    }

    @PostMapping
    public Book create(@RequestBody Book book) {
        // business rule jammed into the controller:
        if (books.existsByIsbn(book.getIsbn())) {
            throw new IllegalStateException("duplicate ISBN");
        }
        return books.save(book);
    }
}

What just happened: The controller is doing three jobs at once — receiving HTTP, enforcing a uniqueness rule, and persisting. That uniqueness check is business logic standing in the doorway. Now we extract it into a service the controller can call:

@Service
public class BookService {

    private final BookRepository books;

    public BookService(BookRepository books) {
        this.books = books;
    }

    public Book create(Book book) {
        if (books.existsByIsbn(book.getIsbn())) {
            throw new DuplicateIsbnException(book.getIsbn());
        }
        return books.save(book);
    }
}
@RestController
@RequestMapping("/api/books")
public class BookController {

    private final BookService service;

    public BookController(BookService service) {
        this.service = service;
    }

    @PostMapping
    public Book create(@RequestBody Book book) {
        return service.create(book);   // controller just delegates
    }
}

What just happened: The @Service annotation registers BookService as a bean (same dependency injection you learned in Phase 2), so Spring constructs it and injects the repository, then injects the service into the controller. The controller's create method shrank to a single line: receive, delegate, return. The duplicate-ISBN rule now lives in one place that a test can call directly with a plain Book object — no HTTP needed. (We'll make DuplicateIsbnException turn into a clean 409 response in Phase 7; for now it just signals the problem.)

@Transactional — all or nothing

📝 A transaction is a unit of work that either fully commits or fully rolls back — there is no "halfway." Imagine an operation that saves a book and decrements an inventory count in two separate writes. If the second write fails, you do not want the first one to stick around: you'd have a book with no matching inventory record, and your data would be quietly lying to you. A transaction guarantees that both happen or neither does.

In Spring you don't manage transactions by hand. You annotate a method, and Spring opens a transaction before it runs and commits when it returns (or rolls back if it throws). The natural home for @Transactional is the service layer, because the service is where a "unit of work" is defined — one business operation, possibly several writes.

@Service
public class BookService {

    private final BookRepository books;
    private final InventoryRepository inventory;

    public BookService(BookRepository books, InventoryRepository inventory) {
        this.books = books;
        this.inventory = inventory;
    }

    @Transactional   // both writes commit together, or neither does
    public Book createWithStock(Book book, int initialStock) {
        Book saved = books.save(book);
        inventory.save(new Inventory(saved.getId(), initialStock));
        return saved;
    }

    @Transactional(readOnly = true)   // a hint: this method only reads
    public List<Book> findAll() {
        return books.findAll();
    }
}

What just happened: On createWithStock, Spring opens a transaction, runs both saves, and commits only if the method finishes cleanly. If inventory.save(...) throws, the book save is rolled back too — you never get the half-written state. On findAll, readOnly = true tells Spring (and the JDBC driver) this method won't modify anything, which lets the database skip some bookkeeping and can perform better. Use readOnly = true for query methods and a plain @Transactional for methods that write.

⚠️ Two gotchas that bite everyone at least once:

  • Self-invocation skips it. @Transactional works through a Spring proxy — Spring wraps your bean in a generated object that starts the transaction before handing the call to your real method. That only happens when the call comes from outside the bean. If method a() in BookService calls this.b() (where b() is @Transactional), the call never leaves the object, never passes through the proxy, and b() runs with no transaction at all — silently. The fix: call across a bean boundary (put b() in another service), not this.b().
  • By default, only unchecked exceptions roll back. Spring rolls back automatically on RuntimeException and Error, but not on checked exceptions, which it lets commit. If a method that throws a checked exception should roll back, say so explicitly: @Transactional(rollbackFor = SomeCheckedException.class).

DTOs vs entities — don't expose your database

So far the controller has been accepting and returning Book — the JPA @Entity from Phase 5. That's the second trap, and it's a common one.

📝 A DTO (Data Transfer Object) is the shape you expose over your API — the JSON a client sends and receives. An entity is the shape you store — a class mapped to a database table. These are two different concerns that happen to look similar today, and the whole point of a DTO is to keep them separate so they can evolve independently. A Java record (see Records & Modern Java) is the perfect tool for a DTO: it's an immutable data carrier in one line, exactly what a request or response body wants to be.

Why pay for the extra classes? Three concrete reasons:

  • Don't leak database internals. Your entity might have an internal createdBy audit field, a soft-delete flag, or a password hash. Serialize the entity straight to JSON and you've published all of it. A DTO exposes only the fields you chose to expose.
  • Avoid over-posting. If clients send a raw Book entity, a malicious or careless request can set fields you never meant them to — an id, an internalRating, a featured flag. With a DTO, only the fields on the DTO can ever be set; everything else is impossible by construction.
  • Decouple the API from the schema. Rename a column or split a table tomorrow, and your public API shouldn't even flinch. The DTO is a contract with your clients; the entity is an implementation detail. Keep them apart and you can change one without breaking the other.

Here are the two shapes side by side, plus the mapping between them:

// Entity — how we STORE a book (from Phase 5)
@Entity
public class Book {
    @Id @GeneratedValue
    private Long id;
    private String title;
    private String author;
    private String isbn;
    private String internalNotes;   // private — must never reach the API
    // getters / setters omitted
}

// DTOs — how we EXPOSE a book. Records: immutable, one line each.
public record CreateBookRequest(String title, String author, String isbn) {}

public record BookResponse(Long id, String title, String author, String isbn) {}
@Service
public class BookService {

    private final BookRepository books;

    public BookService(BookRepository books) {
        this.books = books;
    }

    @Transactional
    public BookResponse create(CreateBookRequest req) {
        if (books.existsByIsbn(req.isbn())) {
            throw new DuplicateIsbnException(req.isbn());
        }
        Book entity = new Book();          // DTO -> entity (map only allowed fields)
        entity.setTitle(req.title());
        entity.setAuthor(req.author());
        entity.setIsbn(req.isbn());

        Book saved = books.save(entity);
        return toResponse(saved);          // entity -> DTO (expose only chosen fields)
    }

    private BookResponse toResponse(Book b) {
        return new BookResponse(b.getId(), b.getTitle(), b.getAuthor(), b.getIsbn());
    }
}

What just happened: The incoming CreateBookRequest has only the three fields a client is allowed to set — no id, no internalNotes — so over-posting is impossible. The service maps that request onto a fresh Book entity field by field, saves it, then maps the saved entity back into a BookResponse that exposes only what we want public. internalNotes never appears in either DTO, so it can never leak. The record accessors (req.isbn(), not req.getIsbn()) come straight from the records phase.

⚠️ Returning entities directly causes real pain, not just leakage. A JPA entity often has lazy relationships — a Book with a lazily-loaded List<Review> that isn't fetched until you touch it. Hand that entity to the JSON serializer and it tries to read every field, triggering surprise database queries mid-serialization (or a LazyInitializationException when the transaction has already closed). DTOs sidestep this entirely: you map exactly the data you want inside the transaction, and the serializer only ever sees a plain, fully-populated record.

Bean Validation — reject bad input at the door

A client just sent you a book with a blank title and an ISBN of "x". Where do you catch that? Not with a pile of if statements at the top of every method — that's the controller-doing-two-jobs problem again. Spring has a declarative answer: Bean Validation, where you annotate the DTO with the rules its data must satisfy, then ask Spring to enforce them automatically.

📝 You put constraint annotations on the DTO's fields, and add @Valid to the controller parameter. When a request arrives, Spring validates the DTO before your method body runs — and if anything fails, your code never executes; the client gets a 400 instead. The rules live with the data they describe, and the check happens in one consistent place.

import jakarta.validation.constraints.*;

public record CreateBookRequest(
    @NotBlank(message = "title is required")
    @Size(max = 200, message = "title must be at most 200 characters")
    String title,

    @NotBlank(message = "author is required")
    String author,

    @Pattern(regexp = "\\d{13}", message = "isbn must be 13 digits")
    String isbn,

    @Email(message = "must be a valid email")
    String contactEmail,

    @Min(value = 0, message = "price cannot be negative")
    int price
) {}
@RestController
@RequestMapping("/api/books")
public class BookController {

    private final BookService service;

    public BookController(BookService service) {
        this.service = service;
    }

    @PostMapping
    public BookResponse create(@Valid @RequestBody CreateBookRequest req) {
        return service.create(req);   // only runs if validation passed
    }
}

What just happened: The constraints (@NotBlank, @Size, @Pattern, @Email, @Min) describe the valid shape of a request right on the record. The @Valid on the @RequestBody parameter is the switch that tells Spring to actually run those checks before calling create. Send a good request and it flows straight through to the service; send a bad one and your method body is never entered.

When validation fails, Spring throws a MethodArgumentNotValidException, which by default produces a 400 Bad Request. The raw response looks roughly like this:

{
  "timestamp": "2026-06-22T10:15:30.123+00:00",
  "status": 400,
  "error": "Bad Request",
  "path": "/api/books",
  "errors": [
    { "field": "title", "defaultMessage": "title is required" },
    { "field": "isbn",  "defaultMessage": "isbn must be 13 digits" }
  ]
}

What just happened: Without writing a single if, the bad request was rejected with a 400 and a list of exactly which fields failed and why — your message strings come straight back to the client. The default shape is a bit noisy and not something you'd ship as-is; in Phase 7 you'll catch MethodArgumentNotValidException in one place and format a clean, consistent error body.

Tie it together

Look at the full path a POST /api/books now travels, each step owned by exactly one layer:

flowchart LR
  A[Controller: @Valid checks DTO] --> B[Controller maps to / passes DTO]
  B --> C[Service: @Transactional logic]
  C --> D[Service maps DTO to Entity]
  D --> E[Repository: save]
  E --> F[Service maps Entity to DTO]
  F --> G[Controller returns JSON]

The controller validates and translates HTTP. The service enforces the rules and owns the transaction. The repository moves rows. Nothing knows more than it needs to.

💡 This layering is the difference between a Spring app you can grow and one you'll rewrite. Each layer has a single, testable job: you can unit-test the service with a fake repository and zero HTTP, slice-test the controller's validation without a database, and trust the repository because it's the framework's code, not yours. That testability is exactly what Phase 8 builds on — and the clean exceptions your service throws are what Phase 7 turns into honest HTTP responses next.

Recap

  1. Three layers, one job each. Controllers speak HTTP, services hold business logic, repositories talk to the database — and each layer only calls the one below it. Logic in the controller is the trap this structure fixes.
  2. @Service beans hold your logic. Move rules out of the controller into a plain bean you can call from anywhere — a controller, a job, or a test — with no HTTP required.
  3. @Transactional makes a method all-or-nothing. Put it on service methods that write; use readOnly = true for queries. ⚠️ It works only through Spring's proxy (so this.method() self-calls skip it), and by default only unchecked exceptions trigger a rollback.
  4. DTOs decouple your API from your database. A record DTO exposes only chosen fields — no leaking internals, no over-posting, no coupling the public contract to the schema. ⚠️ Returning entities directly invites lazy-loading and serialization bugs.
  5. Bean Validation guards the door. Annotate the DTO (@NotBlank, @Size, @Email, @Min, @Pattern) and add @Valid on the controller parameter; Spring rejects bad input with a 400 before your code runs.
  6. The flow: controller validates → maps to/passes a DTO → service (transactional) runs the logic → repository persists → mapped back to a DTO → returned as JSON. One job per layer is what makes the app testable and maintainable.

Quick check

Make sure the layering and its two famous gotchas stuck:

[
  {
    "q": "Why does a @Transactional method silently run with no transaction when called via this.method() from inside the same bean?",
    "choices": [
      "Because @Transactional works through a Spring proxy, and a self-invocation never leaves the object to pass through that proxy",
      "Because @Transactional only applies to controller methods, not service methods",
      "Because transactions are disabled by default and must be turned on in application.yml",
      "Because the method needs to be public and static for the annotation to take effect"
    ],
    "answer": 0,
    "explain": "Spring wraps the bean in a proxy that opens the transaction before delegating to your real method. That interception only happens for calls coming from outside the bean. A this.method() call stays inside the object, bypasses the proxy, and runs untransacted. Call across a bean boundary instead."
  },
  {
    "q": "What is the main reason to return a DTO (e.g. a record) from your controller instead of the JPA @Entity?",
    "choices": [
      "It decouples the API from the database schema and prevents leaking internal fields, over-posting, and lazy-loading serialization bugs",
      "DTOs are saved to the database faster than entities",
      "Entities cannot be converted to JSON at all without a DTO",
      "It is required by Spring — controllers reject entity return types"
    ],
    "answer": 0,
    "explain": "The DTO is your public API contract; the entity is a storage detail. Keeping them separate lets you expose only chosen fields (no leaking internals or over-posting) and avoids lazy-loading/serialization problems — and lets the schema change without breaking clients."
  },
  {
    "q": "What makes Spring actually validate an incoming request body against the constraints on its DTO?",
    "choices": [
      "Adding @Valid to the @RequestBody controller parameter, alongside the constraint annotations on the DTO fields",
      "Annotating the service method with @Transactional",
      "Putting the constraint annotations on the entity instead of the DTO",
      "Nothing — Spring validates every request body automatically"
    ],
    "answer": 0,
    "explain": "The constraints (@NotBlank, @Size, etc.) describe the rules, but @Valid on the parameter is the switch that tells Spring to enforce them before your method body runs. Without @Valid, the annotations are just metadata and no validation happens."
  }
]

← Phase 5: Persistence with Spring Data JPA · Guide overview · Phase 7: Error Handling Done Right →

Check your understanding

1. Why does a @Transactional method silently run with no transaction when called via this.method() from inside the same bean?

2. What is the main reason to return a DTO (e.g. a record) from your controller instead of the JPA @Entity?

3. What makes Spring actually validate an incoming request body against the constraints on its DTO?

Was this page helpful?