Backend
Backend API and Data System Design Foundations
A backend design note covering API boundaries, data ownership, validation, transactions, caching, and operational observability.
When creating a backend API, you first pay attention to the Controller, URL, and JSON response. However, if you go a little further, the API code is directly connected to the data model, transaction, validation, exception handling, testing, log, and deployment environment. Even with simple CRUD, maintenance difficulty varies depending on how the layers are divided, how the repository can be changed, and where failures are handled.
The Spring member management example is small in terms of functionality. Receive and save member names and view the list. However, even in this small example, there are basic questions about backend design. Where should the domain object be placed? Where should the business rules be verified? Can the service code be maintained even if the Repository implementation changes from memory to RDB? Do the tests not affect each other?
An API is a contract, not a list of URLs
When first creating a REST API, it is easy to focus on matching GET, POST, PUT, and DELETE and URLs. However, the operating API is not just a list of URLs, but a contract between the client and server. This includes what requests are allowed, what status codes are used to express success and failure, what the error response structure is, and how pagination and sorting work.
Taking an API that searches a member list as an example, it is easier to handle later in screen and batch processing if page information and filter criteria are expressed together rather than simply returning an array.
GET /api/members?page=1&size=20&status=ACTIVE
Accept: application/json{
"items": [
{
"id": 1,
"name": "Kim Taeyoung",
"status": "ACTIVE",
"createdAt": "2025-12-29T10:00:00+09:00"
}
],
"page": 1,
"size": 20,
"totalCount": 132
}The creation API must have clear request validation and duplication policies.
POST /api/members
Content-Type: application/json{
"name": "Kim Taeyoung",
"email": "taeyoung@example.com"
}In case of success, the identifier of the created resource is returned.
201 Created
Location: /api/members/1{
"id": 1,
"name": "Kim Taeyoung",
"email": "taeyoung@example.com",
"status": "ACTIVE"
}A failure response is also a contract. The error structure must be stable for the screen to know which fields to fix and how to fix them.
{
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"fieldErrors": {
"email": "Email format is invalid"
},
"traceId": "req-20251229-001"
}
`
```traceId` is not a value that explains the cause to the user, but it is a clue for the operator to find the same request in the log. If the API contract is set up this way, frontend, backend, QA, and operators can view the same failure in the same language.
## Divide into tiers from small requirements
The requirements for the membership management example are simple.
```txt
Data
- member id
- name
Feature
- register member
- list members
Initial assumption
- storage is not decided yet
- start with in-memory repositoryYou can create a DB table right here, but if you divide the hierarchy first, it becomes easier to experiment with changing the storage.
Controller Handles HTTP requests and responses
Service Holds core business rules
Repository Provides data access
Domain Represents business objectsMembership domains start out as simple objects.
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;
}
}Repository is placed as an interface. This is so that the Service does not need to know directly whether the implementation is memory, JDBC, or JPA.
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.
public class MemoryMemberRepository implements MemberRepository {
private static final Map<Long, Member> store = new HashMap<>();
private static long sequence = 0L;
@Override
public Member save(Member member) {
member.setId(++sequence);
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();
}
}This code is sufficient for learning purposes, but if it is an actual service, HashMap and long sequence have concurrency problems. At a minimum, you should consider choices like ConcurrentHashMap and AtomicLong. More importantly, the code structure must reveal that the memory store is a temporary implementation.
Service does not hide business rules
A key rule in membership registration is not to allow duplicate names. This rule should not exist in the Controller. This is because the same rules must apply whether it comes in as an HTTP form, a REST API, or is called from a batch job.
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();
}
private void validateDuplicateMember(Member member) {
memberRepository.findByName(member.getName())
.ifPresent(m -> {
throw new IllegalStateException("Member already exists.");
});
}
public List<Member> findMembers() {
return memberRepository.findAll();
}
public Optional<Member> findOne(Long memberId) {
return memberRepository.findById(memberId);
}
}In the first code, the Service can directly create new MemoryMemberRepository(). However, doing so makes testing and implementation replacement difficult. If you change to constructor injection, Service only depends on the Repository interface.
// before
private final MemberRepository memberRepository = new MemoryMemberRepository();
// after
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}This difference may seem small, but it becomes the standard for protecting the service code when moving from Memory Repository to JDBC, JPA, and Spring Data JPA later. Even if the repository changes, business rules should remain unchanged.
Testing starts by breaking state sharing
In memory repository tests, if each test shares the same repository state, it may fail depending on the order. Since the tests must run independently, we clear the storage in @AfterEach.
class MemoryMemberRepositoryTest {
MemoryMemberRepository repository = new MemoryMemberRepository();
@AfterEach
public void afterEach() {
repository.clearStore();
}
@Test
public void save() {
Member member = new Member();
member.setName("spring");
repository.save(member);
Member result = repository.findById(member.getId()).get();
assertThat(result).isEqualTo(member);
}
}Service tests inject a Repository to look at the same repository.
class MemberServiceTest {
MemberService memberService;
MemoryMemberRepository memberRepository;
@BeforeEach
public void beforeEach() {
memberRepository = new MemoryMemberRepository();
memberService = new MemberService(memberRepository);
}
@AfterEach
public void afterEach() {
memberRepository.clearStore();
}
@Test
public void duplicate_member_exception() {
Member member1 = new Member();
member1.setName("spring");
Member member2 = new Member();
member2.setName("spring");
memberService.join(member1);
IllegalStateException e = assertThrows(
IllegalStateException.class,
() -> memberService.join(member2)
);
assertThat(e.getMessage()).isEqualTo("Member already exists.");
}
}What a test checks is not “the code runs.” Check that the rule of duplicate names is not broken, that states are not mixed between tests, and that the service contract is maintained even if the implementation is changed.
MVC is a layer that handles HTTP boundaries
When you attach web MVC, the Controller receives an HTTP request, calls a service, and returns a view or response. The home screen simply connects the / request to the template.
@Controller
public class HomeController {
@GetMapping("/")
public String home() {
return "home";
}
}The registration form displays the screen with GET and receives data with POST.
@Controller
public class MemberController {
private final MemberService memberService;
@Autowired
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:/";
}
}Inquiry takes a list from Service and puts it in Model.
@GetMapping("/members")
public String list(Model model) {
List<Member> members = memberService.findMembers();
model.addAttribute("members", members);
return "members/memberList";
}Switching to a REST API returns JSON instead of View, and DTO validation and exception response format become more important. Whether Spring Boot, FastAPI, or NestJS, the structure is similar. The HTTP boundary deals with request verification and response format, and it is easier to maintain business rules if they are left at the service or use case layer.
DB approach shifts between productivity and control
At first, you can start with a lightweight DB like H2. Runs quickly for development/test purposes and creates tables using DDL.
drop table if exists member cascade;
create table member (
id bigint generated by default as identity,
name varchar(255),
primary key (id)
);Pure JDBC shows the DB access process most directly. You need to handle Connection, PreparedStatement, ResultSet, and close the resource. There is a lot of repetition, but it is good for understanding the basic structure of DB access.
In Spring, you can change the Repository implementation by injecting DataSource.
@Bean
public MemberRepository memberRepository() {
return new JdbcMemberRepository(dataSource);
}JdbcTemplate reduces repetitive code but writes SQL directly. This is an intermediate step to increase productivity while maintaining SQL control.
public class JdbcTemplateMemberRepository implements MemberRepository {
private final JdbcTemplate jdbcTemplate;
public JdbcTemplateMemberRepository(DataSource dataSource) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
}
}JPA reduces the burden of writing SQL through object and table mapping.
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}Data changes in JPA must be performed within a transaction.
@Transactional
public class MemberService {
}Spring Data JPA further reduces the Repository implementation.
public interface SpringDataJpaMemberRepository
extends JpaRepository<Member, Long>, MemberRepository {
Optional<Member> findByName(String name);
}Technology selection depends on what level of control is needed, not “because it’s the latest.” If SQL tuning and complex queries are important, it may be convenient to explicitly handle SQL, and if domain models and CRUD productivity are important, JPA/Spring Data JPA is advantageous.
SQL is the language of API performance and consistency
SQL is not a simple query syntax but a language that deals with data consistency and performance. DDL creates structures, DML manipulates data, and TCL handles transactions.
create table employees (
emp_id int primary key,
name varchar(50) not null,
department varchar(50),
salary decimal(10, 2),
hire_date date default current_date
);Queries must clearly express required columns, conditions, sorting, and aggregation.
select department, avg(salary) as avg_salary
from employees
group by department
having avg(salary) > 4000;JOIN combines relationships between tables according to API responses.
select e.name, d.dept_name
from employees e
inner join departments d
on e.department = d.dept_id;A transaction groups multiple changes into one logical unit.
begin;
update accounts set balance = balance - 100 where acc_id = 1;
update accounts set balance = balance + 100 where acc_id = 2;
commit;
-- rollback;Functions that change multiple tables, such as “order creation” in the API, may result in only partial success if the transaction boundary is set incorrectly. Conversely, transactions that are too wide can create lock contention and performance problems.
Indexes speed up lookups but incur write costs.
create index idx_salary on employees(salary);So, rather than “creating an index because it is frequently looked up,” you should look at WHERE, JOIN, ORDER BY, cardinality, and write frequency together to decide.
NoSQL is flexible, but access patterns must be determined first
MongoDB is a document-based database. It stores documents similar to JSON, and the schema is flexible. For example, book data can contain nested author information and category arrangements in one document.
db.book.insert({
"title": "React",
"author": {
"writer": "Jane",
"translator": "Alice"
},
"price": 40000,
"categories": ["IT", "Web", "Front-End"]
});Searches are also based on document structure and operators.
db.emp.find({ deptno: 30, job: "MANAGER" }, { deptno: 1, ename: 1, job: 1 });
db.emp.find({ $or: [{ sal: 1500 }, { sal: 1600 }] }, { ename: 1, sal: 1 });
db.emp.find({ ename: { $regex: /^S/ } });
db.emp.find().skip(10).limit(2);MongoDB has the advantage of having a flexible schema, but this does not mean that it can be installed without any structure. You must first think about which fields to search, which documents to read together, and the update unit.
Key-value/document-type NoSQL, such as DynamoDB, should be designed more strongly around access patterns. Query efficiently reads based on the partition key, but Scan scans the entire table, increasing costs and delays. If you approach RDB with the habit of thinking about normalization and JOIN first, it is easy to deviate from the DynamoDB design.
RDB
- relationship first
- join and transaction
- flexible query after modeling
NoSQL
- access pattern first
- partition key and sort key
- denormalization and duplication when neededWhen dividing from monolithic to MSA, the first thing to divide is responsibility, not code
When talking about Cloud-Native Application or MSA, containers, Kubernetes, and API Gateway first come to mind, but what you actually need to decide first is the service boundary. If you blindly divide a monolithic application into multiple services, the deployment unit will be divided, but data consistency, network failure, authentication, and observability will become more difficult.
It is usually safer to think of service separation in the following order:
Monolith
-> module by domain
-> clear service interface
-> separate data ownership
-> independent deployment
-> independent scalingJust because you have members, orders, payments, and notifications, there is no need to create four services from scratch. You must first clarify how member information is referenced in orders and payments, how payment failures affect order status, and whether failure to send notifications should block key transactions.
One of the important principles of MSA is that services own their data. When multiple services share one DB table, the actual combination remains the same even though the distribution unit is divided.
avoid
order service -> member table direct query
payment service -> order table direct update
prefer
order service -> member API or cached read model
payment service -> payment result event
order service -> updates order state by own transactionOf course, if all inquiries are processed only through real-time API calls, delays and error propagation will increase. So options such as read model, event, cache, and eventual consistency appear. Here too, the key is to clarify “which data is the standard data for which service” rather than choosing technology.
Common anti-patterns in MSA development include:
distributed monolith
- services are deployed separately
- but always changed and released together
shared database
- multiple services read/write same tables
- schema change becomes cross-team incident
chatty communication
- one user request triggers many synchronous calls
- latency and failure amplification increase
missing observability
- logs exist per service
- but no trace id connects the request pathTo reduce this problem, API Gateway, service discovery, centralized logging, distributed tracing, and contract testing are needed. However, MSA is not completed just by adding tools. Even if you start small with a monolithic code, the boundaries between controller, service, repository, and domain must be clear so that it is easy to determine service boundaries later.
Cache is a performance solution but also a consistency problem
Redis is used for a variety of purposes, including cache, session data, rate limiting, job state storage, and pub/sub. However, adding cache unconditionally does not improve the system. Rather, consistency problems arise between the DB and cache.
When attaching a read cache, you must determine at least the following:
Cache decision
- cache key format
- TTL
- stale data tolerance
- invalidation trigger
- fallback when Redis is unavailable
- metrics: hit rate, latency, error rateFor example, adding caching to member profile searches reduces read delays. However, when member information is modified, you must decide whether to clear the cache immediately or let it expire naturally with a short TTL. In the event of a Redis failure, you must decide whether to fall back to the DB or restrict some functions.
Cache can also hide DB index problems. When the cache hit rate is high, it seems fast, but the moment the cache is empty, the DB may not be able to withstand it. Therefore, the DB query plan and index must be checked before and after introducing the cache.
AOP and Observability: Separating Common Concerns
If you want to measure the execution time of each service method, adding System.currentTimeMillis() to all methods will mix the core logic and measurement code.
public Long join(Member member) {
long start = System.currentTimeMillis();
try {
validateDuplicateMember(member);
memberRepository.save(member);
return member.getId();
} finally {
long finish = System.currentTimeMillis();
System.out.println("join " + (finish - start) + "ms");
}
}AOP separates these common concerns.
@Component
@Aspect
public class TimeTraceAop {
@Around("execution(* hello.hellospring..*(..))")
public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
try {
return joinPoint.proceed();
} finally {
long finish = System.currentTimeMillis();
long timeMs = finish - start;
System.out.println("END: " + joinPoint + " " + timeMs + "ms");
}
}
}Spring AOP inserts common logic before and after calling the actual object through a proxy. For training examples, time output is sufficient, but for operations it leads to structured logging, metrics, and tracing. In FastAPI, middleware/dependency and in NestJS, interceptor/guard/filter play a similar role.
Cleanup
Even starting from a small membership management example, the core of the backend design is revealed quite a bit. The Controller handles the HTTP boundary, the Service maintains business rules, and the Repository hides the data access implementation. Tests break state sharing, and database access techniques choose between productivity and control.
SQL is the basis for handling transactions, consistency, JOINs, and indexes, and NoSQL such as MongoDB or DynamoDB should be selected depending on access patterns and scalability requirements. Redis can increase performance, but it also requires a consistency strategy.
Ultimately, a backend API is not a list of endpoints, but a system that sits on top of the data and operations boundary. The API design is solid only when it comes to what requests come in, what rules are applied, what transactions are reflected in what storage, and what logs and indicators can be used to track when a problem occurs.
Unauthorized copying, redistribution, and automated scraping are not permitted. Please cite the original URL when referencing this article.