Spring Boot for Angular and NestJS Developers
If you already know Angular and NestJS, you know most of Spring Boot's core ideas. A concept-by-concept guide to the JVM stack, built on Spring Boot 4
There's a detail most Java tutorials never mention to JavaScript developers: NestJS was modelled on Spring. Modules, a dependency injection container, decorators wrapping controllers and services, guards and interceptors around the request pipeline — those ideas came from the Java world, and Angular carried the same DI philosophy to the frontend.
So if you've built anything real with Angular and NestJS, you already understand Spring Boot's architecture. What you're missing is the vocabulary and the type system, not the mental model.
This guide maps what you know onto what you're learning.
The concept map
| You know | Spring Boot equivalent |
|---|---|
| @Injectable() service | @Service bean |
| @Controller() | @RestController |
| @Module() | Component scanning + @Configuration |
| Constructor injection | Constructor injection (identical) |
| @Get() / @Post() | @GetMapping / @PostMapping |
| @Body() | @RequestBody |
| @Param() / @Query() | @PathVariable / @RequestParam |
| DTO class + class-validator | record + Jakarta Bean Validation |
| TypeORM repository | Spring Data JPA repository |
| ValidationPipe | @Valid on the controller parameter |
| Exception filter | @RestControllerAdvice |
| Guards | Spring Security filter chain |
| .env + ConfigModule | application.yml + @ConfigurationProperties |
| npm run start:dev | ./mvnw spring-boot:run |
The single biggest practical difference: Spring resolves dependencies at startup, not at runtime. A missing bean is a startup failure with a readable error, not a undefined is not a function at 2am. It takes some adjusting, and then you stop wanting to go back.
Setting up
Use start.spring.io. Pick Maven, Java 21, and Spring Boot 4.1.
Minimum dependencies for a REST API:
- Spring Web — the MVC stack and embedded Tomcat
- Spring Data JPA — the ORM layer
- PostgreSQL Driver
- Validation — Jakarta Bean Validation
- Spring Boot DevTools — hot reload, the closest thing to
--watch
Then:
./mvnw spring-boot:run
No global install, no separate server. The mvnw wrapper downloads the right Maven version itself, so the project is reproducible on any machine — this is what people mean when they say Java tooling is boring, and boring is the compliment.
On Java versions: Spring Boot 4.1 requires Java 17 as a minimum and supports far newer releases. Pick Java 21 for new projects — it's an LTS release and it gives you virtual threads, which matter later.
The entry point
@SpringBootApplication
public class ApiApplication {
public static void main(String[] args) {
SpringApplication.run(ApiApplication.class, args);
}
}
That single annotation bundles three things: it marks the configuration class, it enables auto-configuration, and it starts component scanning from this package downwards.
Component scanning is the part to internalise. There is no app.module.ts listing every provider. Spring walks the package tree from your main class, finds anything annotated @Component, @Service, @Repository or @RestController, and registers it. Which means one rule matters: keep every class in a sub-package of your main class, or Spring won't find it.
Auto-configuration is the other half. Spring inspects your classpath and configures accordingly — find a PostgreSQL driver and a datasource URL, and it wires a connection pool without you writing a line. It feels like magic until you run with --debug, which prints exactly which auto-configurations matched and why.
Layers
The structure is the one you already use in NestJS:
src/main/java/com/example/api/
├── ApiApplication.java
├── user/
│ ├── UserController.java // HTTP layer
│ ├── UserService.java // business logic
│ ├── UserRepository.java // data access
│ ├── User.java // JPA entity
│ └── dto/
│ ├── CreateUserRequest.java
│ └── UserResponse.java
└── config/
└── SecurityConfig.java
Package by feature, not by layer. A user package containing everything about users beats four packages named controllers, services, repositories and models — the same reason you group Angular code by feature module.
The controller
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public List<UserResponse> findAll() {
return userService.findAll();
}
@GetMapping("/{id}")
public UserResponse findById(@PathVariable Long id) {
return userService.findById(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserResponse create(@Valid @RequestBody CreateUserRequest request) {
return userService.create(request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
userService.delete(id);
}
}
Two things to notice. There is no @Autowired on the constructor — Spring injects a single constructor automatically, and constructor injection is the recommended form (more on why in the best-practices article). And @Valid is all you need to trigger validation; there is no pipe to register globally.
DTOs as records
Java records are the closest thing to a TypeScript interface with runtime presence — immutable, with the constructor, getters, equals, hashCode and toString generated for you.
public record CreateUserRequest(
@NotBlank(message = "Name is required")
String name,
@NotBlank @Email(message = "Must be a valid email address")
String email,
@NotBlank @Size(min = 12, message = "Password must be at least 12 characters")
String password
) {}
public record UserResponse(Long id, String name, String email, Instant createdAt) {
public static UserResponse from(User user) {
return new UserResponse(
user.getId(), user.getName(), user.getEmail(), user.getCreatedAt()
);
}
}
The annotations map almost one-to-one onto class-validator: @NotBlank for @IsNotEmpty(), @Email for @IsEmail(), @Size for @Length(), @Min/@Max for @Min()/@Max().
The service
@Service
@Transactional(readOnly = true)
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
public List<UserResponse> findAll() {
return userRepository.findAll().stream()
.map(UserResponse::from)
.toList();
}
public UserResponse findById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", id));
return UserResponse.from(user);
}
@Transactional
public UserResponse create(CreateUserRequest request) {
if (userRepository.existsByEmail(request.email())) {
throw new DuplicateResourceException("Email already registered");
}
User user = new User();
user.setName(request.name());
user.setEmail(request.email());
user.setPasswordHash(passwordEncoder.encode(request.password()));
return UserResponse.from(userRepository.save(user));
}
}
@Transactional(readOnly = true) at class level with @Transactional overriding it on writes is a common and useful default: read methods get a lighter transaction, write methods get a real one.
The entity
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
@Column(name = "password_hash", nullable = false)
private String passwordHash;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private Instant createdAt;
// getters and setters
}
Note that entities are not records. JPA needs a no-arg constructor and mutable fields to hydrate objects and track changes — records are for DTOs, classes are for entities.
The repository
This is where Spring Data earns its reputation:
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
List<User> findByNameContainingIgnoreCase(String fragment);
}
You write the interface. Spring generates the implementation at startup by parsing the method names. findByEmail becomes SELECT * FROM users WHERE email = ?. You inherit findAll, findById, save, delete, pagination and sorting without writing anything.
When derived queries get unwieldy, drop to JPQL:
@Query("SELECT u FROM User u WHERE u.createdAt > :since ORDER BY u.createdAt DESC")
List<User> findRecent(@Param("since") Instant since);
Configuration
application.yml replaces your .env plus ConfigModule:
spring:
application:
name: my-api
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: ${DB_USER}
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
server:
port: 8080
app:
jwt:
secret: ${JWT_SECRET}
expiration-minutes: 60
${DB_USER} reads an environment variable, so secrets stay out of the repository. Bind your own properties to a typed record:
@ConfigurationProperties(prefix = "app.jwt")
public record JwtProperties(String secret, int expirationMinutes) {}
Injected anywhere as JwtProperties, fully typed, validated at startup. No process.env.JWT_SECRET returning undefined in production.
Profiles handle environments — application-dev.yml, application-prod.yml, activated with SPRING_PROFILES_ACTIVE=prod.
Error handling
Spring 6 introduced ProblemDetail, an implementation of RFC 9457, and it's the right default for a new API:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ProblemDetail handleNotFound(ResourceNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Validation failed");
Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
f -> Optional.ofNullable(f.getDefaultMessage()).orElse("invalid"),
(a, b) -> a));
problem.setProperty("errors", errors);
return problem;
}
}
One class, applied across every controller — the equivalent of a global NestJS exception filter, and your Angular error interceptor now has a predictable shape to parse.
CORS for your Angular frontend
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:4200")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE")
.allowedHeaders("*")
.allowCredentials(true);
}
}
Move the origin into configuration before you deploy — a wildcard origin in production is one of the most common findings in a first security review.
What to build first
Reading gets you maybe a third of the way. Build a small API with one well-modelled domain: entity, repository, service, controller, validation, error handling, and an Angular frontend consuming it. A narrow, finished project teaches more than a broad, abandoned one — and it's the thing you can actually show.
The second article covers what separates a Spring Boot application that works from one that survives production.
Written while migrating my own backend work from NestJS to the JVM. If you spot something wrong, tell me — I'd rather be corrected than confident.