Java + Spring Boot track

Lesson 25 · Part 6: Spring Boot

Services and Layers

For small examples, it's okay to have all the logic in one controller class, but as things get bigger, separation of layers becomes critical to maintainability. Learn…

Part 6: Spring Boot3 min readFree lesson

For small examples, it's okay to have all the logic in one controller class, but as things get bigger, separation of layers becomes critical to maintainability. Learn the Spring camp's customary three-tier structure and the DTO concept that safely passes data between them.

Learning Objectives

  • Know the difference in roles between Controller / Service / Repository
  • Use @Service, @Repository annotations
  • Know the constructor injection pattern
  • Distinguish between DTO and domain object
  • Apply the principle of “business logic is a service”

Key concepts

1) 3 layer picture

HTTP Request → Controller → Service → Repository → DB
(thin) (thick) (thin)
  • Controller: HTTP input/output only (input verification/DTO conversion)
  • Service: Business logic (verification, transaction, domain behavior)
  • Repository: Data storage access (DB · in-memory)

2) Constructor injection

@Service
public class MemberService {
    private final MemberRepository repository;

    public MemberService(MemberRepository repository) {
        this.repository = repository;
    }
}
  • Guaranteed immutability with final field
  • Easy mock injection during testing
  • If there is a circular dependency, it is exposed at point Compile/Start

3) DTO vs domain

Type Location Features
Domain Inside Service/Repository Including business activities, external exposure
DTO Controller boundary Tailored to input/output shape, serialization friendly

If the domain is exposed as is by the controller, the internal structure is tied to the outside and is vulnerable to change.

4) @Repository

@Repository
public class MemberRepository { ... }

@Service / @Repository / @Controller is @Component, which only has a different meaning. Spring automatically registers with component scanning.

See as example

Example 1 — Member.java: Domain Object

package com.codingnow.lecture.spring25.domain;

public class Member {
    private Long id;
    private String name;
    private String email;

    public Member(Long id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }
    // getters...
}

Example 2 — MemberRepository.java: Memory Store

@Repository
public class MemberRepository {
    private final Map<Long, Member> store = new ConcurrentHashMap<>();
    private final AtomicLong seq = new AtomicLong();

    public Member save(Member m) { ... }
    public Optional<Member> findById(Long id) { ... }
    public List<Member> findAll() { ... }
    public boolean deleteById(Long id) { ... }
}

Example 3 — MemberService.java: Business Logic

@Service
public class MemberService {
    private final MemberRepository repository;
    public MemberService(MemberRepository repository) { this.repository = repository; }

    public MemberResponse register(MemberCreateRequest req) {
        if (req.name() == null || req.name().isBlank())
            throw new IllegalArgumentException("name required");
        Member saved = repository.save(new Member(null, req.name(), req.email()));
        return MemberResponse.from(saved);
    }
}

Example 4 — MemberController.java: Using DTO

@RestController
@RequestMapping("/api/members")
public class MemberController {
    private final MemberService service;
    public MemberController(MemberService service) { this.service = service; }

    @PostMapping
    public MemberResponse create(@RequestBody MemberCreateRequest req) {
        return service.register(req);
    }

    @GetMapping
    public List<MemberResponse> all() { return service.findAll(); }
}

run

cd 06_Spring_Boot/25_Services-and_Layers
mvn spring-boot:run
curl -X POST http://localhost:8085/api/members \
     -H "Content-Type: application/json" \
     -d '{"name":"Jisoo","email":"[email protected]"}'
#{"id":1,"name":"Jisoo","email":"[email protected]"}

curl http://localhost:8085/api/members
#[{"id":1,"name":"Jisoo","email":"[email protected]"}]

Common Mistakes

  1. Controller handles all business logic → Difficult to test
  2. Exposing the domain as is → External API is tied to the internal structure
  3. Use @Autowired field injection → Use constructor injection
  4. Start with two-way dependency → Spring refuses to start
  5. Service learns HTTP information → layer invasion

Cleanup

  • 3-tier separation is more effective as the scale increases
  • If you create a boundary with DTO, you will become stronger against change.
  • Constructor injection + final is recommended for dependencies

Try it yourself

cd 06_Spring_Boot/25_Services-and_Layers
mvn spring-boot:run

Example files

The runnable files used in this lesson. Copy them into your own project and experiment.

src/main/java/com/codingnow/lecture/spring25/Application.javaJava
package com.codingnow.lecture.spring25;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
src/main/java/com/codingnow/lecture/spring25/controller/MemberController.javaJava
package com.codingnow.lecture.spring25.controller;

import java.util.List;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.codingnow.lecture.spring25.dto.MemberCreateRequest;
import com.codingnow.lecture.spring25.dto.MemberResponse;
import com.codingnow.lecture.spring25.service.MemberService;

@RestController
@RequestMapping("/api/members")
public class MemberController {
    private final MemberService service;

    public MemberController(MemberService service) {
        this.service = service;
    }

    @PostMapping
    public MemberResponse create(@RequestBody MemberCreateRequest req) {
        return service.register(req);
    }

    @GetMapping
    public List<MemberResponse> all() {
        return service.findAll();
    }
}
src/main/java/com/codingnow/lecture/spring25/domain/Member.javaJava
package com.codingnow.lecture.spring25.domain;

/** Member domain object.*/
public class Member {
    private Long id;
    private final String name;
    private final String email;

    public Member(Long id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }

    public Long getId() { return id; }
    public String getName() { return name; }
    public String getEmail() { return email; }

    public void assignId(Long id) {
        this.id = id;
    }
}
src/main/java/com/codingnow/lecture/spring25/dto/MemberCreateRequest.javaJava
package com.codingnow.lecture.spring25.dto;

/** Membership request DTO.*/
public record MemberCreateRequest(String name, String email) {}
src/main/java/com/codingnow/lecture/spring25/dto/MemberResponse.javaJava
package com.codingnow.lecture.spring25.dto;

import com.codingnow.lecture.spring25.domain.Member;

/** Member response DTO.*/
public record MemberResponse(Long id, String name, String email) {
    public static MemberResponse from(Member m) {
        return new MemberResponse(m.getId(), m.getName(), m.getEmail());
    }
}
src/main/java/com/codingnow/lecture/spring25/repository/MemberRepository.javaJava
package com.codingnow.lecture.spring25.repository;

import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

import org.springframework.stereotype.Repository;

import com.codingnow.lecture.spring25.domain.Member;

/** Memory-based membership store.*/
@Repository
public class MemberRepository {
    private final Map<Long, Member> store = new ConcurrentHashMap<>();
    private final AtomicLong seq = new AtomicLong();

    public Member save(Member m) {
        if (m.getId() == null) m.assignId(seq.incrementAndGet());
        store.put(m.getId(), m);
        return m;
    }

    public Optional<Member> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }

    public List<Member> findAll() {
        return List.copyOf(store.values());
    }

    public boolean deleteById(Long id) {
        return store.remove(id) != null;
    }
}
src/main/java/com/codingnow/lecture/spring25/service/MemberService.javaJava
package com.codingnow.lecture.spring25.service;

import java.util.List;

import org.springframework.stereotype.Service;

import com.codingnow.lecture.spring25.domain.Member;
import com.codingnow.lecture.spring25.dto.MemberCreateRequest;
import com.codingnow.lecture.spring25.dto.MemberResponse;
import com.codingnow.lecture.spring25.repository.MemberRepository;

/** Membership business logic.*/
@Service
public class MemberService {
    private final MemberRepository repository;

    public MemberService(MemberRepository repository) {
        this.repository = repository;
    }

    public MemberResponse register(MemberCreateRequest req) {
        if (req.name() == null || req.name().isBlank()) {
            throw new IllegalArgumentException("name required");
        }
        Member saved = repository.save(new Member(null, req.name(), req.email()));
        return MemberResponse.from(saved);
    }

    public List<MemberResponse> findAll() {
        return repository.findAll().stream().map(MemberResponse::from).toList();
    }
}
src/main/resources/application.propertiesText
server.port=8085
spring.application.name=lecture-spring25

Assignment

Problem — Product Registration/Inquiry API

  • Reference solution: shown below
  • Core concepts: 3-layer separation, DTO conversion, constructor injection

Requirements

  • Domain: Product(id, name, price)
  • DTO input: ProductCreateRequest(name, price), output: ProductResponse(id, name, price)
  • Service verification: If price is 0 or less, IllegalArgumentException
  • Controller: GET(list), POST(create) to /api/products
  • Storage: in-memory (ConcurrentHashMap)

Expected behavior

$ curl -X POST localhost:8086/api/products -H "Content-Type: application/json" \
       -d '{"name":"Pen","price":1500}'
{"id":1,"name":"Pen","price":1500}

$ curl localhost:8086/api/products
[{"id":1,"name":"Pen","price":1500}]

Check the correct answer

Look at the Maven project in the reference solution below.

Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

pom.xmlXML
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.6</version>
        <relativePath/>
    </parent>

    <groupId>com.codingnow</groupId>
    <artifactId>lecture-spring25-hw</artifactId>
    <version>1.0.0</version>

    <properties>
        <java.version>21</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
src/main/java/com/codingnow/lecture/spring25hw/Application.javaJava
package com.codingnow.lecture.spring25hw;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
src/main/java/com/codingnow/lecture/spring25hw/controller/ProductController.javaJava
package com.codingnow.lecture.spring25hw.controller;

import java.util.List;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.codingnow.lecture.spring25hw.dto.ProductCreateRequest;
import com.codingnow.lecture.spring25hw.dto.ProductResponse;
import com.codingnow.lecture.spring25hw.service.ProductService;

@RestController
@RequestMapping("/api/products")
public class ProductController {
    private final ProductService service;

    public ProductController(ProductService service) {
        this.service = service;
    }

    @PostMapping
    public ProductResponse create(@RequestBody ProductCreateRequest req) {
        return service.create(req);
    }

    @GetMapping
    public List<ProductResponse> all() {
        return service.all();
    }
}
src/main/java/com/codingnow/lecture/spring25hw/domain/Product.javaJava
package com.codingnow.lecture.spring25hw.domain;

public class Product {
    private Long id;
    private final String name;
    private final long price;

    public Product(Long id, String name, long price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }

    public Long getId() { return id; }
    public String getName() { return name; }
    public long getPrice() { return price; }

    public void assignId(Long id) { this.id = id; }
}
src/main/java/com/codingnow/lecture/spring25hw/dto/ProductCreateRequest.javaJava
package com.codingnow.lecture.spring25hw.dto;

public record ProductCreateRequest(String name, long price) {}
src/main/java/com/codingnow/lecture/spring25hw/dto/ProductResponse.javaJava
package com.codingnow.lecture.spring25hw.dto;

import com.codingnow.lecture.spring25hw.domain.Product;

public record ProductResponse(Long id, String name, long price) {
    public static ProductResponse from(Product p) {
        return new ProductResponse(p.getId(), p.getName(), p.getPrice());
    }
}
src/main/java/com/codingnow/lecture/spring25hw/repository/ProductRepository.javaJava
package com.codingnow.lecture.spring25hw.repository;

import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

import org.springframework.stereotype.Repository;

import com.codingnow.lecture.spring25hw.domain.Product;

@Repository
public class ProductRepository {
    private final Map<Long, Product> store = new ConcurrentHashMap<>();
    private final AtomicLong seq = new AtomicLong();

    public Product save(Product p) {
        if (p.getId() == null) p.assignId(seq.incrementAndGet());
        store.put(p.getId(), p);
        return p;
    }

    public List<Product> findAll() {
        return List.copyOf(store.values());
    }
}
src/main/java/com/codingnow/lecture/spring25hw/service/ProductService.javaJava
package com.codingnow.lecture.spring25hw.service;

import java.util.List;

import org.springframework.stereotype.Service;

import com.codingnow.lecture.spring25hw.domain.Product;
import com.codingnow.lecture.spring25hw.dto.ProductCreateRequest;
import com.codingnow.lecture.spring25hw.dto.ProductResponse;
import com.codingnow.lecture.spring25hw.repository.ProductRepository;

@Service
public class ProductService {
    private final ProductRepository repository;

    public ProductService(ProductRepository repository) {
        this.repository = repository;
    }

    public ProductResponse create(ProductCreateRequest req) {
        if (req.price() <= 0) throw new IllegalArgumentException("price > 0");
        Product saved = repository.save(new Product(null, req.name(), req.price()));
        return ProductResponse.from(saved);
    }

    public List<ProductResponse> all() {
        return repository.findAll().stream().map(ProductResponse::from).toList();
    }
}
src/main/resources/application.propertiesText
server.port=8086
spring.application.name=lecture-spring25-hw

Learn this lesson with a mentor

Work through the Java + Spring Boot track on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now