Backend

Spring Boot Backend Application Structure and Data Access Flow

Notes on Spring Boot application structure, controller-service-repository boundaries, dependency injection, and data access flow.

Spring Boot Backend Application Structure and Data Access Flow

When first dealing with Spring Boot, annotations such as @Controller, @Service, @Repository, @Autowired, and @Transactional come to mind first. However, if you approach it by memorizing annotations, the structure is not clear. What's more important is to see which objects take on which responsibilities when a request comes in, and which parts should remain unchanged even when the way the data is stored changes.

Based on a small member management example, the flow becomes simple. The requirement is to register and search members. Initially, we assume that the database is unspecified and start with memory storage. Even if you later change the storage implementation to H2, JDBC, JdbcTemplate, JPA, or Spring Data JPA, the core flow of the controller and service should not change significantly. At this point, layering, interfacing, DI, and testing naturally become necessary.

What to check first when creating a project

Based on Spring Boot 3.x, Java 17 or higher is required. The packages of javax.persistence.Entity and javax.annotation.PostConstruct seen in previous Spring examples were changed to jakarta.persistence.Entity and jakarta.annotation.PostConstruct after Jakarta EE conversion. If you miss this difference when creating a project, a situation may arise where the dependency appears to be correct, but the import is broken.

An initial project can be started in Spring Initializr as follows:

Project: Gradle
Language: Java
Spring Boot: 3.x
Packaging: Jar
Java: 17 or 21
Dependencies: Spring Web, Thymeleaf, Spring Data JPA, H2 Database
`

```build.gradle` takes a minimal configuration to handle web requests, render templates, and run tests, rather than adding a lot of dependencies from scratch.

```groovy
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.0'
    id 'io.spring.dependency-management' version '1.1.5'
}

group = 'hello'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'com.h2database:h2'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

test {
    useJUnitPlatform()
}

Spring Boot provides a static welcome page if resources/static/index.html exists. However, if a controller handles the / route, that controller takes precedence. Knowing this priority allows you to distinguish between situations where static files are not visible and whether MVC routing is working as intended.

It is easy to divide the request processing method into three types.

The first flows to check in Spring Web are static content, MVC template, and API response. Static content provides files under resources/static without a controller. On the other hand, in MVC, the controller receives a request, stores data in the model, and then renders the view template. The API returns JSON or string directly as the response body without going through the View.

@Controller
public class HelloController {

    @GetMapping("/hello-mvc")
    public String helloMvc(@RequestParam String name, Model model) {
        model.addAttribute("name", name);
        return "hello-template";
    }

    @GetMapping("/hello-api")
    @ResponseBody
    public HelloResponse helloAPI(@RequestParam String name) {
        return new HelloResponse(name);
    }

    public record HelloResponse(String name) {
    }
}

If @ResponseBody is added, HTTPMessageConverter operates instead of View Resolver. If it is a string, StringHTTPMessageConverter is selected, and if it is an object, a Jackson-based JSON converter is usually selected. This difference is important when creating an API server. This is because the same controller method can be rendered as HTML or a JSON response depending on the return type and annotation.

Make the member domain small and separate the layers

The requirements for the membership management example are simple.

  • Members have id and name.
  • You can register members.
  • You can view all members.
  • Members with the same name will not be registered repeatedly.
  • The repository may change later.

You can create a database table directly from this requirement, but if you separate the domain and storage interface first, you will be less shaken by changes in storage method.

package hello.hellospring.domain;

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

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}
package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import java.util.List;
import java.util.Optional;

public interface MemberRepository {
    Member save(Member member);
    Optional<Member> findById(Long id);
    Optional<Member> findByName(String name);
    List<Member> findAll();
}

Initially, the memory implementation is sufficient. However, HashMap and simple long sequence in the example code are not safe for concurrency. If it is a web server where actual requests come in at the same time, you should use ConcurrentHashMap and AtomicLong, or go further and prevent duplication with database constraints and transactions.

public class MemoryMemberRepository implements MemberRepository {
    private final Map<Long, Member> store = new ConcurrentHashMap<>();
    private final AtomicLong sequence = new AtomicLong();

    @Override
    public Member save(Member member) {
        member.setId(sequence.incrementAndGet());
        store.put(member.getId(), member);
        return member;
    }

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

    @Override
    public Optional<Member> findByName(String name) {
        return store.values().stream()
            .filter(member -> member.getName().equals(name))
            .findAny();
    }

    @Override
    public List<Member> findAll() {
        return new ArrayList<>(store.values());
    }

    public void clearStore() {
        store.clear();
    }
}

Services do not hide business rules

The function works even if the controller directly calls the repository. However, if rules such as duplicate member verification are scattered across controllers, the same rules will be repeated in APIs, MVC screens, deployments, and tests. The service collects these domain rules in one place.

public class MemberService {
    private final MemberRepository memberRepository;

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

    public Long join(Member member) {
        validateDuplicateMember(member);
        memberRepository.save(member);
        return member.getId();
    }

    public List<Member> findMembers() {
        return memberRepository.findAll();
    }

    private void validateDuplicateMember(Member member) {
        memberRepository.findByName(member.getName())
            .ifPresent(existing -> {
                throw new IllegalStateException("Member already exists.");
            });
    }
}

The point to note here is that new MemoryMemberRepository() is not created directly inside the service. The service depends only on the MemberRepository interface. External settings determine which implementation to include. Thanks to this structure, most of the service code is maintained even when moving from memory storage to JDBC or JPA.

DI is a design tool that changes the location of object creation

Dependency injection in Spring is not simply adding @Autowired. This method prevents objects from directly creating their own dependencies and assembles an implementation suitable for the execution environment externally.

When you use component scan, Spring finds classes with @Controller, @Service, and @Repository and registers them as beans.

@Service
public class MemberService {
    private final MemberRepository memberRepository;

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

Conversely, if there is a high possibility of explicitly replacing the implementation, registering the bean with Java settings makes the flow easier to read.

@Configuration
public class SpringConfig {

    @Bean
    public MemberService memberService(MemberRepository memberRepository) {
        return new MemberService(memberRepository);
    }

    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }
}

DI methods include field injection, setter injection, and constructor injection, but in most cases, it is better to choose constructor injection. It can prevent objects from being created without essential dependencies, and it is easy to include fake implementations in tests.

Tests should separate the state and order of the repository

Memory repositories are easy to test, but they suffer from the problem of remaining state. If the data saved by one test affects the next test, success or failure changes depending on the test order. So I clear the storage after each test.

class MemoryMemberRepositoryTest {
    MemoryMemberRepository repository = new MemoryMemberRepository();

    @AfterEach
    void afterEach() {
        repository.clearStore();
    }

    @Test
    void save() {
        Member member = new Member();
        member.setName("spring");

        repository.save(member);

        Member result = repository.findById(member.getId()).orElseThrow();
        assertThat(result).isEqualTo(member);
    }
}

Service testing injects the repository implementation to check business rules.

class MemberServiceTest {
    MemoryMemberRepository repository;
    MemberService memberService;

    @BeforeEach
    void beforeEach() {
        repository = new MemoryMemberRepository();
        memberService = new MemberService(repository);
    }

    @AfterEach
    void afterEach() {
        repository.clearStore();
    }

    @Test
    void duplicateMemberIsRejected() {
        Member member1 = new Member();
        member1.setName("spring");

        Member member2 = new Member();
        member2.setName("spring");

        memberService.join(member1);

        assertThatThrownBy(() -> memberService.join(member2))
            .isInstanceOf(IllegalStateException.class);
    }
}

With this test structure in place, even if you change the storage implementation, you can immediately check whether rules such as “member redundancy verification” are broken.

MVC screens distinguish between input objects and domains.

The member registration screen displays a form with GET /members/new and receives a registration request with POST /members/new. At this time, if the input value of the HTML form is received as a separate form object rather than being tied directly to the domain object, screen input and changes to the domain model can be separated.

@Controller
public class MemberController {
    private final MemberService memberService;

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

    @GetMapping("/members/new")
    public String createForm() {
        return "members/createMemberForm";
    }

    @PostMapping("/members/new")
    public String create(MemberForm form) {
        Member member = new Member();
        member.setName(form.getName());
        memberService.join(member);
        return "redirect:/";
    }

    @GetMapping("/members")
    public String list(Model model) {
        model.addAttribute("members", memberService.findMembers());
        return "members/memberList";
    }
}
public class MemberForm {
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

Thymeleaf repeatedly renders members sent from the server.

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>Name</th>
    </tr>
  </thead>
  <tbody>
    <tr th:each="member : ${members}">
      <td th:text="${member.id}"></td>
      <td th:text="${member.name}"></td>
    </tr>
  </tbody>
</table>

Although this example is simple, the same pattern can be applied to an API server. Input is received with the Request DTO, the domain rules are processed by the service, and an external response is configured with the Response DTO. The key is to ensure that domain objects and external API schema are not tightly tied.

By switching to a REST API, the controller's responsibilities become clearer. The controller converts HTTP requests into application commands and converts the results of the service into response DTOs. If the domain object is exposed as a response as is, changes to internal fields may lead to changes to the API contract, so a separate form to be displayed to the outside is provided.

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

    public MemberAPIController(MemberService memberService) {
        this.memberService = memberService;
    }

    @PostMapping
    public ResponseEntity<MemberResponse> create(@Valid @RequestBody CreateMemberRequest request) {
        Long memberId = memberService.join(request.toCommand());
        return ResponseEntity
            .status(HTTPStatus.CREATED)
            .body(new MemberResponse(memberId, request.name()));
    }
}

public record CreateMemberRequest(
    @NotBlank String name
) {
    public CreateMemberCommand toCommand() {
        return new CreateMemberCommand(name);
    }
}

public record MemberResponse(Long id, String name) {
}

In this structure, CreateMemberRequest represents HTTP input, and CreateMemberCommand represents an application command that the service understands. In small examples, combining the two will work, but when elements such as field validation, permissions, request origin, idempotency key, and file upload are included, it is safer to separate the input DTO and domain commands.

The same goes for exception responses. If an exception that occurred in a service is exposed as an HTML error page or stack trace, it is difficult for API users to reliably handle the cause of failure. By adding @ControllerAdvice, you can customize the error response format in one place.

@RestControllerAdvice
public class APIExceptionHandler {

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<APIErrorResponse> handleBadRequest(IllegalArgumentException ex) {
        return ResponseEntity.badRequest()
            .body(new APIErrorResponse("BAD_REQUEST", ex.getMessage()));
    }

    @ExceptionHandler(IllegalStateException.class)
    public ResponseEntity<APIErrorResponse> handleConflict(IllegalStateException ex) {
        return ResponseEntity.status(HTTPStatus.CONFLICT)
            .body(new APIErrorResponse("CONFLICT", ex.getMessage()));
    }
}

public record APIErrorResponse(String code, String message) {
}

During operations, error codes are often more important than error messages. This is because front-end, batch, and external integration systems must branch into stable code, not human-readable sentences. Therefore, API responses should be viewed as contracts, not only for success cases but also for failure cases.

DB access technology is the difference between repetitive code and modeling method

H2 is good for light use in development and testing. In Spring Boot 3.x, it is recommended to match the H2 2.x series, and once created in file mode and connected in TCP mode, the application and Console can be used together.

spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.jpa.hibernate.ddl-auto=none
spring.jpa.show-sql=true

The table starts small.

drop table if exists member cascade;

create table member (
    id bigint generated by default as identity,
    name varchar(255),
    primary key (id)
);

Pure JDBC must directly handle Connection, PreparedStatement, and ResultSet. If you go through this process once, you can see what kind of repetition occurs in DB access. However, there is too much resource cleanup, exception conversion, and mapping code to continue writing directly in production code.

JdbcTemplate reduces this repetition. SQL is written directly, but the framework handles connection management and repetitive code.

public class JdbcTemplateMemberRepository implements MemberRepository {
    private final JdbcTemplate jdbcTemplate;

    public JdbcTemplateMemberRepository(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    @Override
    public Optional<Member> findById(Long id) {
        List<Member> result = jdbcTemplate.query(
            "select * from member where id = ?",
            memberRowMapper(),
            id
        );
        return result.stream().findAny();
    }

    private RowMapper<Member> memberRowMapper() {
        return (rs, rowNum) -> {
            Member member = new Member();
            member.setId(rs.getLong("id"));
            member.setName(rs.getString("name"));
            return member;
        };
    }
}

JPA has a slightly different perspective. It not only reduces SQL repetition, but also centers the mapping of objects and tables. An entity has an identifier and fields, and an EntityManager tracks changes within its persistence context.

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
}
public class JpaMemberRepository implements MemberRepository {
    private final EntityManager em;

    public JpaMemberRepository(EntityManager em) {
        this.em = em;
    }

    @Override
    public Member save(Member member) {
        em.persist(member);
        return member;
    }

    @Override
    public Optional<Member> findById(Long id) {
        return Optional.ofNullable(em.find(Member.class, id));
    }

    @Override
    public List<Member> findAll() {
        return em.createQuery("select m from Member m", Member.class)
            .getResultList();
    }
}

Data changes must be performed within a transaction.

@Transactional
public Long join(Member member) {
    validateDuplicateMember(member);
    memberRepository.save(member);
    return member.getId();
}

Spring Data JPA goes one step further and provides basic CRUD and method name-based queries without having to create a repository implementation yourself.

public interface SpringDataJpaMemberRepository
        extends JpaRepository<Member, Long>, MemberRepository {

    Optional<Member> findByName(String name);
}

If you look at technology selection simply as “the latest technology is better,” you miss something. Pure JDBC requires the most code, but is good for understanding the basics of DB access. JdbcTemplate reduces repetition while maintaining control of SQL. JPA increases productivity by focusing on object models and transaction boundaries. Spring Data JPA eliminates repetitive repository implementations, but requires understanding the behavior of the queries and persistence contexts being generated.

It is natural to handle transactions at the service layer rather than the repository. The Repository hides how to store and retrieve data, and the Service knows which changes should be grouped into one work unit. Although the sign-up example is simple, the same applies to larger systems: redundant verification and storage must be handled as a single unit.

@Transactional
public Long join(CreateMemberCommand command) {
    validateDuplicateMember(command.name());

    Member member = new Member(command.name());
    memberRepository.save(member);

    return member.getId();
}

However, it is difficult to completely prevent simultaneous requests simply by verifying duplicate application code. This is because two requests come in at almost the same time and both pass findByName before attempting to save. So DB’s unique constraint should be the last line of defense.

alter table member
add constraint uk_member_name unique (name);

At the service layer, processing is required to convert DB constraint violations into errors appropriate for the domain.

try {
    memberRepository.save(member);
} catch (DataIntegrityViolationException ex) {
    throw new DuplicateMemberException("Member already exists.");
}

With this structure, the controller responds to DuplicateMemberException with 409 Conflict, the service expresses business rules, and the DB maintains consistency in concurrency situations. Rather than duplicating the same rules across multiple layers, the method is to divide the defense lines that each layer can take charge of.

Integration tests check Spring container and DB boundaries

While unit tests quickly verify business rules, integration tests ensure that Spring configuration, transactions, and actual database access fit together.

@SpringBootTest
@Transactional
class MemberServiceIntegrationTest {

    @Autowired
    MemberService memberService;

    @Autowired
    MemberRepository memberRepository;

    @Test
    void join() {
        Member member = new Member();
        member.setName("spring");

        Long savedId = memberService.join(member);

        Member found = memberRepository.findById(savedId).orElseThrow();
        assertThat(found.getName()).isEqualTo(member.getName());
    }
}

If you add @Transactional to a test, it will be rolled back after the test is finished. Since test data does not remain in the DB, repeated execution becomes easier. However, one should not confuse transaction boundaries in operational code with rollback convenience in tests. There may be issues with lazy loading and flush timing that only pass in the test, so if necessary, flush explicitly or have a separate slice test.

AOP is a device to bring out common interests

If you say you want to measure the execution time of membership registration and query methods, putting System.currentTimeMillis() in every method will blur the business logic. Logic that repeats itself across multiple layers, such as timing, logging, transactions, and permission checking, is better separated into common concerns.

@Aspect
@Component
public class TimeTraceAop {

    @Around("execution(* hello.hellospring..*(..))")
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();

        try {
            return joinPoint.proceed();
        } finally {
            long elapsed = System.currentTimeMillis() - start;
            System.out.println(joinPoint.getSignature() + " " + elapsed + "ms");
        }
    }
}

Spring AOP operates based on proxy. Instead of injecting the actual object as is, the container injects a proxy object, and the proxy executes common logic before and after the method call and then calls the actual object. Because of this structure, there are situations where AOP is not applied when a method is directly called within the same object, such as self-invocation. AOP is convenient, but requires an understanding of proxy boundaries and coverage.

Cleanup

It is easier to understand the Spring Boot backend structure when viewed as a process of separating responsibilities rather than a list of annotations. Controllers handle HTTP requests and responses, services collect domain rules, and repositories hide data access methods. DI creates implementation replacement and testability, and transactions demarcate data changes. AOP separates repetitive concerns into multiple layers.

The small membership management example is simple, but this flow extends to larger services as well. Even if the storage changes from memory to RDB, the screen changes from Thymeleaf to REST API, and the deployment environment changes from local to containers and Kubernetes, the core questions are the same. At what layer will the request be interpreted, where will the rules be collected, what abstraction will data access be hidden behind, and how will failures and changes be checked through testing?

Unauthorized copying, redistribution, and automated scraping are not permitted. Please cite the original URL when referencing this article.