Build a CRUD Feature Like a Senior Developer: Beyond Create, Read, Update, Delete
Most CRUD tutorials look great until you put the feature in front of real users. Then you get the “why did my change disappear?”, “why is…
Build a CRUD Feature Like a Senior Developer: Beyond Create, Read, Update, Delete
Most CRUD tutorials look great until you put the feature in front of real users. Then you get the “why did my change disappear?”, “why is the list slow?”, “why does the UI show a blank error?”, and “why did this delete succeed for someone without access?” kind of problems.

This article is about Build a CRUD Feature Like a Senior Developer in the way teams actually ship: clean boundaries, predictable APIs, validation on both sides, useful error messages, pagination, and safe updates.
I’ll use a simple example: managing Projects. The goal isn’t a copy-paste app. It’s a reusable mental model and a set of patterns you can apply to the next feature you build.
Why “Build a CRUD Feature Like a Senior Developer: Beyond Create, Read, Update, Delete” matters
The “CRUD is done” moment is misleading. The UI can create, list, edit, delete. Great. In real projects, the feature is still fragile if it lacks:
- DTO boundaries so your database model doesn’t leak into the API
- Validation that works even when the UI is bypassed
- Consistent errors that the frontend can render
- Pagination and sorting so lists don’t DOS your own backend
- Concurrency protection so one user doesn’t overwrite another
If you want a deeper “why” for beginners, this pairs nicely with The CRUD Is Not the Point: What Beginners Should Learn from Building One.
The naive CRUD approach (and why it breaks later)
This usually looks harmless at the beginning:
- Expose JPA entities directly from controllers
- Use
save(entity)for both create and update - Return raw exception messages or stack traces on errors
- Fetch
/projectswith no pagination
It “works” for demos. It breaks when requirements show up: hide fields, add search, show friendly errors, prevent overwrites, and enforce rules.
Step 1: Define the API you actually want (DTOs, not entities)
If your controller returns entities, you are tying the frontend to your persistence model. The first time you add a relationship, lazy loading, or a sensitive field, you’ll feel the pain.
Create explicit DTOs for request and response. Keep them small and predictable.
Spring Boot DTOs
// ProjectCreateRequest.java
public record ProjectCreateRequest(
String name,
String description
) {}
// ProjectUpdateRequest.java
public record ProjectUpdateRequest(
String name,
String description,
Long version
) {}
// ProjectResponse.java
public record ProjectResponse(
Long id,
String name,
String description,
Long version,
java.time.Instant createdAt,
java.time.Instant updatedAt
) {}
Why include version? That’s your concurrency safety net (we’ll use it soon).
If DTOs vs entities is still fuzzy, link this for later: DTOs vs Entities: One of the Most Important Lessons for New Backend Developers.
Step 2: Add validation where it belongs (both sides)
Client-side validation is for user experience. Server-side validation is for correctness. Real users, scripts, and broken clients will hit your API.
Spring Boot validation annotations
// ProjectCreateRequest.java
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record ProjectCreateRequest(
@NotBlank
@Size(max = 80)
String name,
@Size(max = 500)
String description
) {}
In your controller, add @Valid so the rules are enforced.
@RestController
@RequestMapping("/api/projects")
public class ProjectController {
private final ProjectService service;
public ProjectController(ProjectService service) {
this.service = service;
}
@PostMapping
public ProjectResponse create(@Valid @RequestBody ProjectCreateRequest req) {
return service.create(req);
}
}
For the frontend side, Angular reactive forms are the right tool for anything beyond trivial inputs. Angular has great official docs on reactive forms.
Angular form with matching constraints
// project-form.component.ts
import { FormControl, FormGroup, Validators } from '@angular/forms';
form = new FormGroup({
name: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.maxLength(80)]
}),
description: new FormControl('', {
nonNullable: true,
validators: [Validators.maxLength(500)]
})
});
If you want the bigger picture (and common pitfalls), this is a direct match: Validation Belongs on Both Sides: Angular Forms and Spring Boot APIs.
Step 3: Consistent errors, not “something went wrong”
This is where many beginners get stuck: the backend returns a 400/500, Angular logs an error, and the UI shows nothing useful.
Make error responses predictable. A simple format is enough:
// ApiError.java
public record ApiError(
String code,
String message,
java.util.Map<String, String> fieldErrors
) {}
Then map common exceptions using @RestControllerAdvice:
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiError handleValidation(MethodArgumentNotValidException ex) {
var fieldErrors = new java.util.HashMap<String, String>();
ex.getBindingResult().getFieldErrors().forEach(err ->
fieldErrors.put(err.getField(), err.getDefaultMessage()));
return new ApiError("VALIDATION_ERROR", "Validation failed", fieldErrors);
}
@ExceptionHandler(EntityNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ApiError handleNotFound(EntityNotFoundException ex) {
return new ApiError("NOT_FOUND", ex.getMessage(), java.util.Map.of());
}
}
On the Angular side, you can render fieldErrors next to inputs and show message as a toast/banner.
Step 4: Read is not just “GET all”: pagination, sorting, and search
A list endpoint without pagination is fine until it isn’t. The day someone imports 20,000 rows, your “simple CRUD” becomes a performance incident.
Spring Data pagination
@GetMapping
public Page<ProjectResponse> list(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "name") String sort
) {
return service.list(page, size, sort);
}
In the service, keep the mapping from entity to DTO in one place.
public Page<ProjectResponse> list(int page, int size, String sort) {
var pageable = PageRequest.of(page, size, Sort.by(sort).ascending());
return repo.findAll(pageable).map(this::toResponse);
}
Angular: keep query params in the URL
This is a senior-level habit that pays off fast: pagination state should survive refresh and be shareable.
// project-list.component.ts (sketch)
this.route.queryParamMap.subscribe(params => {
const page = Number(params.get('page') ?? 0);
const size = Number(params.get('size') ?? 20);
this.load(page, size);
});
load(page: number, size: number) {
return this.http.get<Page<Project>>('/api/projects', { params: { page, size } })
.subscribe(res => this.page = res);
}
(Define a small Page<T> interface on the frontend that matches Spring’s JSON.)
Step 5: Update without overwriting data (optimistic locking)
The update endpoint is where “Build a CRUD Feature Like a Senior Developer: Beyond Create, Read, Update, Delete” becomes real. Two users open the same project, both edit, the last save wins, and the first person’s changes vanish. The bug report will read “randomly lost my work”.
Use optimistic locking with a @Version column.
JPA entity with version
@Entity
public class Project {
@Id
@GeneratedValue
private Long id;
@Version
private Long version;
@Column(nullable = false, length = 80)
private String name;
@Column(length = 500)
private String description;
// createdAt/updatedAt omitted for brevity
}
Update flow: read, apply, save
public ProjectResponse update(Long id, ProjectUpdateRequest req) {
var project = repo.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Project not found: " + id));
// version check is handled by JPA when saving, as long as the entity version matches
if (!project.getVersion().equals(req.version())) {
throw new OptimisticLockingFailureException("Project was updated by someone else");
}
project.setName(req.name());
project.setDescription(req.description());
var saved = repo.save(project);
return toResponse(saved);
}
In the UI, keep the version hidden but present in the form model. If you get a conflict error, tell the user to refresh and reapply changes.
Step 6: Delete is a business decision (soft delete and integrity)
Many apps cannot truly delete records once they are referenced. “Delete project” might need to mean “archive project”. If you hard delete and later need audit history, you will regret it.
A pragmatic starting point:
- Implement soft delete with an
archivedflag - Hide archived items by default
- Allow admins to view archived items
You don’t have to do this for every feature, but you should consciously decide. Senior CRUD is about decisions, not endpoints.
Common mistakes I keep seeing (and how to avoid them)
- Putting logic in controllers: keep controllers thin, move rules to services. If you want a clear rule of thumb, read Spring Boot Controllers: What They Should and Should Not Do.
- Returning different error shapes: Angular can’t render errors consistently if every endpoint responds differently.
- No DTO mapping: “We’ll add DTOs later” often becomes “we can’t change the API without breaking everything”.
- No pagination: it will work in dev and fail in production. That’s the worst kind of bug.
- Blind save on update: it invites lost updates and accidental nulling of fields.
A senior-friendly CRUD checklist you can reuse
- Contract: Do I have request/response DTOs with stable fields?
- Validation: Are constraints enforced on the server and mirrored in the form?
- Errors: Do I return consistent error JSON with field errors for 400s?
- List endpoint: Do I paginate, sort, and keep state in URL query params?
- Updates: Do I prevent lost updates with a version field?
- Deletes: Is “delete” actually delete, archive, or “not allowed”?
Next steps
If you want to Build a CRUD Feature Like a Senior Developer: Beyond Create, Read, Update, Delete consistently, pick one existing CRUD screen you have and add just two improvements this week: pagination and consistent error handling. Those two changes alone will make the feature feel “real”.
If you’re building with Angular + Spring Boot, comment with what part of CRUD keeps biting you: validation, updates, error messages, or performance. I’ll gladly follow up with a focused deep dive.
메타데이터
- post_id
- e34954d16d2a
- slug
- build-a-crud-feature-like-a-senior-developer-beyond-create-read-update-delete-e34954d16d2a
- url
- https://medium.com/from-tutorials-to-real-applications/build-a-crud-feature-like-a-senior-developer-beyond-create-read-update-delete-e34954d16d2a
- canonical_url
- https://medium.com/from-tutorials-to-real-applications/build-a-crud-feature-like-a-senior-developer-beyond-create-read-update-delete-e34954d16d2a
- author_url
- https://medium.com/@thecodingdon
- status
- ok
- fetched_at
- 2026-08-06 21:42:47