Когда в большом проекте сходятся Java-бэкенд и браузерный интерфейс, команда делает выбор: два отдельных приложения с двумя инфраструктурами или один организм с общей сборкой и общим релизом. Связка Spring Boot и Angular за годы превратилась в классику корпоративной разработки: Java-часть отвечает за данные, безопасность и интеграции, TypeScript-часть за интерактив на экранах. Оба инструмента взрослые, оба с сильными типами, и в крупной системе, где за фичу отвечают разные люди, типизированный контракт между слоями экономит недели отладки.
Ниже разобран путь от версий и контракта REST-интерфейса до сборки единого артефакта, безопасности и монорепозитория. По пути пригодятся примеры кода, потому что фраза "у нас общий контракт" стоит ровно столько, сколько стоит её проверка компилятором и тестом.
Версии и минимальные требования связки Java и TypeScript
Требования обеих сторон сверяют до старта проекта, а не посреди спринта. Spring Boot начиная с версии 3.0, вышедшей в ноябре 2022 года, требует Java 17 как минимум, и эта планка сохранилась в актуальной линейке 4.x. Сборка самого фреймворка из исходников уже требует JDK 25, и это хороший намёк командам: обновляться придётся регулярно. Отдельно стоит вспомнить виртуальные потоки из Java 21: с версии 3.2 Spring Boot умеет пускать обработку запросов на них, включается одной строкой в свойствах и экономит память на нагруженных узлах.
Angular живёт быстрее: актуальная версия v22, при этом новые проекты давно генерируются без модулей NgModule, standalone-компоненты стали умолчанием начиная с 17-й версии. Тогда же в шаблоны пришёл новый синтаксис управления, условие @if и цикл @for вместо директив со звёздочками, а реактивные сигналы, появившиеся в версии 16, постепенно стали главным механизмом отслеживания состояния в компонентах. Сборка ведётся на актуальном LTS-релизе Node, для конфигурации Node закрепляется в сборочных скриптах, чтобы у каждого коллеги и в конвейере стояла одна и та же версия.
Типовая конфигурация бэкенда умещается в несколько строк свойств, и по ней уже видно, из чего собирается рабочий узел.
spring.application.name=field-portal
server.port=8080
spring.datasource.url=jdbc:postgresql://db:5432/portal
spring.datasource.username=portal
spring.jpa.hibernate.ddl-auto=validate
spring.flyway.enabled=true
spring.threads.virtual.enabled=true
Здесь спрятаны решения уровня архитектуры: схема базы версионируется миграциями Flyway, Hibernate не создаёт таблицы сам, а только сверяет схему с сущностями, а пул соединений HikariCP, идущий по умолчанию, забирает тонкую настройку на себя.
Контракт REST-интерфейса и обмен данными через DTO
Граница между слоями проходит через DTO, и в Java для них идеально подходят записи: неизменяемые, лаконичные, без геттеров-сеттеров из прошлого. Сущность базы не должна попадать в JSON напрямую, иначе любое изменение таблицы превращается в поломку внешнего контракта. Запись описывает ровно то, что уезжает на клиент, и не полем больше.
public record TaskDto(
Long id,
String title,
String assignee,
LocalDate dueDate
) {
static TaskDto from(Task task) {
return new TaskDto(task.getId(), task.getTitle(),
task.getAssignee(), task.getDueDate());
}
}
Контроллер остаётся тонким: проверка входа, вызов логики, маппинг в DTO. Валидация описывается аннотациями на теле запроса.
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskRepository repository;
TaskController(TaskRepository repository) {
this.repository = repository;
}
@GetMapping
List<TaskDto> findAll() {
return repository.findAll().stream().map(TaskDto::from).toList();
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
TaskDto create(@Valid @RequestBody TaskCreateRequest request) {
Task saved = repository.save(Task.from(request));
return TaskDto.from(saved);
}
}
В проектах побольше контракт перестают вести руками: библиотека springdoc-openapi строит OpenAPI-схему по контроллерам автоматически, а по схеме на стороне фронтенда генерируются TypeScript-типы. Правка поля в DTO тогда ломает сборку клиента ещё до ревью, и рассинхрон слоёв обнаруживает компилятор, а не пользователь в проде.
Единый артефакт сборки когда фронтенд уезжает внутрь JAR
Классическая боль смежных команд в том, что бэкенд и интерфейс живут в разных сборках и расходятся в релизах. Связка Spring Boot и Angular позволяет собрать один исполняемый JAR, внутри которого лежит и API, и статика интерфейса. У эксплуатации исчезает вопрос, какую версию фронта подкладывать к какой версии API.
Плагин frontend-maven-plugin ставит Node нужной версии прямо в сборку Maven и запускает скрипты из package.json до упаковки. Прод-сборка Angular CLI пишет результат в статические ресурсы Spring Boot.
{
"scripts": {
"build:prod": "ng build --configuration production --output-path ../src/main/resources/static"
}
}
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>1.15.1</version>
<executions>
<execution>
<phase>generate-resources</phase>
<goals>
<goal>install-node-and-npm</goal>
<goal>npm</goal>
</goals>
<configuration>
<nodeVersion>v22.14.0</nodeVersion>
<arguments>run build:prod</arguments>
</configuration>
</execution>
</executions>
</plugin>
Сборка единого артефакта в конвейере обычно идёт по такому маршруту:
- Maven через frontend-maven-plugin ставит Node фиксированной версии и выполняет npm ci по lock-файлу;
- Angular CLI собирает прод-версию с хешами в именах файлов и кладёт результат в статические ресурсы Spring Boot;
- Юнит-тесты обеих сторон выполняются одной командой mvn verify ещё до упаковки;
- Spring Boot упаковывает классы, статику и зависимости в исполняемый JAR, который стартует одной командой java -jar.
У единого артефакта есть нюанс с глубокими ссылками: маршрут интерфейса вроде страницы конкретной задачи обязан открывать точку входа, а не отдавать ошибку 404. Для этого в Spring MVC добавляют компактный контроллер, который по известным путям делает внутренний переход на index.html. Кэширование решается парой решений: файлы сборки несут хеш в имени и отдаются с агрессивным кэшированием, а index.html запрещает кэшировать вовсе, тогда обновление подхватывается сразу после выкладки нового JAR.
Клиент Angular от инжекций до функциональных перехватчиков
Современный клиент Angular выглядит на порядок компактнее кода пятилетней давности. Зависимости приходят через функцию inject, данные из HttpClient превращаются в сигнал через toSignal, и компонент подписки не пишет вовсе.
import { HttpClient } from "@angular/common/http";
import { Injectable, inject } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
@Injectable({ providedIn: "root" })
export class TaskApi {
private readonly http = inject(HttpClient);
readonly tasks = toSignal(this.http.get<TaskDto[]>("/api/tasks"), {
initialValue: [],
});
}
Перехватчики запросов с версии 15 пишутся как функции, классы со множеством зависимостей ушли в прошлое. Функциональный перехватчик получает запрос и следующий обработчик, добавляет заголовок авторизации и живёт в одном небольшом файле.
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthStore);
const token = auth.token();
if (token === null) {
return next(req);
}
return next(req.clone({
setHeaders: { Authorization: "Bearer " + token },
}));
};
Регистрируется перехватчик одной строкой в списке провайдеров приложения.
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
],
};
Шаблоны с новым синтаксисом читаются почти как текст: условие @if, цикл @for со обязательным ключом track, ноль директив со звёздочками.
@if (api.tasks().length > 0) {
<table class="tasks">
@for (task of api.tasks(); track task.id) {
<tr>
<td>{{ task.title }}</td>
<td>{{ task.assignee }}</td>
</tr>
}
</table>
}
В дев-режиме интерфейс запускается своим сервером с горячей перезагрузкой, а запросы с префиксом /api уходят на локальный бэкенд через настроенный прокси, поэтому у фронтендера нет причин пересобирать Java-часть ради правки вёрстки.
Безопасность от токенов до CSRF и ролевой модели доступа
Схема авторизации выбирается до первой строки кода, потому что она определяет и бэкенд, и поведение клиента. Сессионный вариант с cookie удобен внутренним порталам: сервер хранит сессию, cookie уходит автоматически, но тогда обязателен CSRF-токен, ведь cookie прикрепляется к запросам с любого сайта. Вариант с токенами в заголовке Authorization не зависит от сессий на сервере, горизонтальное масштабирование проще, и CSRF-защита не требуется, пока токен живёт в памяти приложения, а не в cookie.
Spring Security закрывает оба варианта готовыми модулями. Стартер oauth2-resource-server проверяет входящие JWT: подпись сверяется с ключами провайдера, роли разбираются из утверждений токена. Ролевая модель включается аннотацией EnableMethodSecurity, а правило уровня метода умещается в одну строку.
@EnableMethodSecurity
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable);
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll());
return http.build();
}
}
@PreAuthorize("hasRole('DISPATCHER')")
void delete(@PathVariable Long id) {
repository.deleteById(id);
}
На стороне клиента решение про хранение токена это компромисс: память приложения не переживает перезагрузку, зато токен недоступен скриптам со страницы при типичной утечке, а localStorage удобен, но читается любым скриптом. Поэтому для внутренних порталов чаще выбирают короткоживущий токен в памяти плюс обновление по защищённой cookie. Пароли хранятся только в виде хеша, BCrypt остаётся выбором по умолчанию, а поле входа помечается атрибутом autocomplete со значением username, чтобы менеджеры паролей не путались.
Тестирование связки и конвейер непрерывной интеграции
Тесты в такой архитектуре делятся на три горизонта. Слой веб-логики проверяется через MockMvc: запрос, ответ, статус, форма JSON без поднятия сервера. Слой интеграций проверяется на настоящей базе через Testcontainers, причём с версии 3.1 Spring Boot подхватывает контейнер по аннотации @ServiceConnection и сам прописывает реквизиты подключения. Контракт фиксируется тестами по OpenAPI-схеме, нарушения формы ответа ловятся в конвейере.
Фронтенд проверяет себя TestBed-окружением с фальшивым бэкендом через HttpTestingController: компонент получает заготовленные ответы и не зависит от живого API. Ведомый годами прогонщик Karma уходит на покой, новые проекты собирают тесты на Jest или Web Test Runner, и это приятная новость для конвейера, где фронтовые проверки становятся быстрее.
Связывает всё одна команда mvn verify: она прогоняет тесты бэкенда, собирает интерфейс, запускает его проверки и падает целиком, если сломалось что-то одно. Зелёная сборка означает, что пара "фронт плюс бэк" согласована, а окружения отличаются только настройками.
Монорепозиторий и типичные ошибки смежных команд разработки
Зрелые команды держат связку в одном репозитории: корень с pom.xml и каталог frontend рядом. Мотивация практичная: фича, затрагивающая контракт, приезжает одним запросом на слияние, где видны и контроллер, и клиентский сервис, и тесты обеих сторон. Инструмент Nx помогает тем, у кого в рабочем пространстве живут общие библиотеки схем типов, но и без него правило одно: один запрос на слияние, одна версия контракта.
Список типичных ошибок повторяется из проекта в проект: типы на клиенте, продублированные руками и оторванные от DTO, кэширование index.html, из-за которого видны старые чанки, забытый дев-прокси, из-за которого запросы бьют не туда, и рассинхрон релизов, когда интерфейс ждёт поле, которого ещё нет в API. Лечится одинаково: генерация типов из схемы, честные версии в путях вроде /api/v1 и единая сборка.
Пара Spring Boot и Angular не модная, зато предсказуемая: обе стороны развиваются, не ломая совместимость ради эффекта. Для корпоративного портала смена версии фреймворка тогда не превращается в переписывание половины системы, а команда тратит силы на задачи бизнеса, а не на склейку инструментов.