Controller → Service → Repository
Every request through a vehicle valuation flows through three layers with three separate jobs — and mixing them up is how a codebase rots.
Spring Boot apps are conventionally organized into three layers, and the convention exists for a reason that goes well beyond 'that's how the tutorials do it.' A Controller handles HTTP: it parses the incoming request, validates shape, and serializes a response. A Service holds business logic: the rules, the orchestration, the decisions that make your application your application rather than a generic CRUD wrapper. A Repository handles data access: it knows how to get rows in and out of a database and nothing else. Each layer has exactly one reason to change, which is the whole point — when the rule for how you weight a Carfax score against a Black Book score changes, you touch the Service. When you switch from a REST API to also exposing a GraphQL endpoint, you touch the Controller. When you migrate a column type in Postgres, you touch the Repository. Three different changes, three different files, no collateral damage.
Picture the shape of a single request in our scenario: a client calls GET /vehicles/{vin}/valuation. VehicleController receives it, extracts the VIN path variable, and calls VehicleValuationService.valuate(vin). The service is where the real work happens — it might check a Redis cache, call out to one or more of BlackBookClient, CarfaxClient, and VisClient, run the results through a rules engine, and decide on a final offer. Along the way it asks VehicleRepository to load or save pricing_rule data from Postgres. The repository does not know why it's being asked, and the controller does not know how the valuation was computed — each layer trusts the one below it to do its job and exposes only what the layer above needs.
What crosses each boundary matters as much as the layers themselves. Between the outside world and the Controller you have DTOs (Data Transfer Objects) — plain shapes like ValuationResponse that exist purely to describe the wire format of a request or response, with no business behavior attached. Between the Controller and the Service you typically pass domain models, or in simpler services the same DTOs, but the Service should never leak its internal working state upward. Between the Service and the Repository you pass entities — classes annotated for JPA that map directly onto database tables, like a PricingRule entity mapping onto the pricing_rule table. Keeping these three shapes distinct, even when a project is young and it's tempting to reuse one class everywhere, is what stops a database column rename from becoming an API-breaking change for your clients.
The rule that a controller should never call a repository directly is not bureaucracy — it's the mechanism that keeps business logic in one place. If VehicleController called VehicleRepository.findByVin(vin) straight away and returned the entity as-is, you would have skipped the valuation logic entirely: no cache check, no calls to the pricing providers, no guardrails from the rules engine. Worse, if a second controller (say, an internal admin endpoint) needed the same valuation, it would either duplicate that logic or, more likely, someone would copy-paste half of it and the two endpoints would slowly drift apart. Routing everything through VehicleValuationService means there is exactly one place where 'what is this vehicle worth' is decided, no matter how many entry points eventually call it.
This discipline also has a direct testing payoff you will feel immediately once you write tests. A Controller test can mock VehicleValuationService and assert purely on HTTP concerns: status codes, JSON shape, header handling — no database, no coroutines, no external providers involved. A Service test can mock VehicleRepository and the three ValuationProviderClient implementations and assert purely on business rules: does a stale Carfax record cause the right fallback, does a no-buy guardrail actually block an offer. A Repository test, backed by Testcontainers running a real Postgres, asserts purely on data access: does the query return what you expect, does the Liquibase migration actually produce the schema the entity assumes. Because the layers do not know about each other's internals, each test class stays narrow and fast, and a failure tells you exactly which layer broke.
It helps to see the skeleton before the flesh. VehicleController is annotated @RestController and depends on VehicleValuationService through its constructor; its methods are thin — extract input, delegate, wrap the result. VehicleValuationService is annotated @Service and depends on VehicleRepository plus the three provider clients; its methods contain the actual decision-making. VehicleRepository extends Spring Data JPA's JpaRepository and mostly declares query method signatures rather than implementations — Spring generates the SQL for you from the method name or an @Query annotation. None of these three classes constructs its own dependencies; all of them receive their collaborators from the outside, which is the subject of the very next lesson.
One more thing worth internalizing now, because it will keep coming up: 'layer' describes a direction of knowledge, not a folder name. You can absolutely organize your codebase by feature (a vehicle package containing its own controller, service, and repository) rather than by technical layer (a controllers package, a services package, a repositories package), and plenty of well-run Spring Boot codebases do exactly that. What must never happen, regardless of folder layout, is a lower layer reaching upward — a Repository must never know about a Service, and a Service must never know about HTTP status codes. Knowledge flows one direction: Controller knows about Service, Service knows about Repository and the provider clients, and nothing knows about anything above it.
@RestController@RequestMapping("/vehicles")class VehicleController(private val valuationService: VehicleValuationService,) {@GetMapping("/{vin}/valuation")suspend fun getValuation(@PathVariable vin: String): ResponseEntity<ValuationResponse> {val result = valuationService.valuate(vin)return ResponseEntity.ok(result.toResponse())}}data class ValuationResponse(val vin: String,val offerCents: Long,val decision: String,)
The Controller layer: thin, HTTP-only, delegating everything to the Service. Requires a real Spring Boot context to run, so it does not run in-browser.
@Serviceclass VehicleValuationService(private val repository: VehicleRepository,private val blackBook: BlackBookClient,private val carfax: CarfaxClient,private val vis: VisClient,) {suspend fun valuate(vin: String): Valuation {val rules = repository.findActiveRulesFor(vin)val quotes = listOf(blackBook.quote(vin),carfax.quote(vin),vis.quote(vin),)return applyRulesEngine(rules, quotes)}}
The Service layer: business logic and orchestration, with no knowledge of HTTP. Requires the provider clients and repository to be wired by Spring, so it does not run in-browser.
interface VehicleRepository : JpaRepository<PricingRule, Long> {@Query("SELECT r FROM PricingRule r WHERE r.vinPrefix = :vinPrefix AND r.active = true")suspend fun findActiveRulesFor(vinPrefix: String): List<PricingRule>}@Entity@Table(name = "pricing_rule")class PricingRule(@Id @GeneratedValue val id: Long? = null,val vinPrefix: String,val active: Boolean,)
The Repository layer: a Spring Data JPA interface with no implementation body — Spring generates the query. Requires a real Postgres connection, so it does not run in-browser.
🧠 Check your understanding
0/1 · 0/1 answered1. VehicleController needs to expose a new internal endpoint that returns raw pricing_rule rows for an admin dashboard, unmodified by any business logic. What is the correct way to do this?