Backend Architecture¶
Архитектура и стандарты backend-разработки в AqStream.
Архитектурное решение: Spring MVC¶
Все бизнес-сервисы используют классический Spring MVC (servlet-based, blocking I/O).
flowchart TB
subgraph Gateway["API Gateway (WebFlux — исключение)"]
GW["Spring Cloud Gateway"]
end
subgraph Services["Business Services (Spring MVC)"]
US["User Service"]
ES["Event Service"]
PS["Payment Service"]
NS["Notification Service"]
MS["Media Service"]
AS["Analytics Service"]
end
Gateway --> Services
Почему Spring MVC¶
| Критерий | Spring MVC | WebFlux |
|---|---|---|
| Отладка | Простой stack trace | Сложный async stack |
| JPA/Hibernate | Нативная поддержка | Требует R2DBC |
| Код | Простой, понятный | Reactive chains |
| Производительность | Достаточна для нас | Нужна при 100K+ req/s |
// ✅ ПРАВИЛЬНО — Spring MVC
@RestController
@RequestMapping("/api/v1/events")
@RequiredArgsConstructor
public class EventController {
private final EventService eventService;
@GetMapping("/{id}")
public ResponseEntity<EventDto> getById(@PathVariable UUID id) {
return ResponseEntity.ok(eventService.findById(id));
}
}
// ❌ НЕПРАВИЛЬНО — WebFlux (не использовать в бизнес-сервисах)
@GetMapping("/{id}")
public Mono<EventDto> getById(@PathVariable UUID id) {
return eventService.findById(id); // НЕТ!
}
Структура сервиса¶
Каждый сервис состоит из 4 модулей:
event-service/
├── event-service-api/ # DTO, Events, Exceptions
├── event-service-service/ # Business logic, Controllers
├── event-service-db/ # Entities, Repositories, Migrations
└── event-service-client/ # Feign client (опционально)
Модуль API¶
Публичные контракты сервиса:
event-service-api/src/main/java/com/aqstream/event/api/
├── dto/
│ ├── EventDto.java
│ ├── CreateEventRequest.java
│ └── UpdateEventRequest.java
├── event/
│ ├── EventCreatedEvent.java
│ └── EventPublishedEvent.java
└── exception/
└── EventNotFoundException.java
Модуль Service¶
Бизнес-логика и контроллеры:
event-service-service/src/main/java/com/aqstream/event/
├── EventServiceApplication.java
├── controller/
│ └── EventController.java
├── service/
│ └── EventService.java
├── mapper/
│ └── EventMapper.java
└── config/
├── SecurityConfig.java
└── RabbitConfig.java
Модуль DB¶
Persistence layer:
event-service-db/src/main/java/com/aqstream/event/db/
├── entity/
│ └── EventEntity.java
└── repository/
└── EventRepository.java
event-service-db/src/main/resources/db/changelog/
├── db.changelog-master.xml
└── changes/
└── 001-create-events-table.xml
Слои приложения¶
flowchart TB
subgraph Presentation
Controller["Controller"]
ExHandler["Exception Handler"]
end
subgraph Business
Service["Service"]
Mapper["Mapper"]
end
subgraph Persistence
Repository["Repository"]
Entity["Entity"]
end
Controller --> Service
Service --> Repository
Service --> Mapper
Repository --> Entity
Controller¶
@RestController
@RequestMapping("/api/v1/events")
@RequiredArgsConstructor
@Tag(name = "Events", description = "Event management API")
public class EventController {
private final EventService eventService;
@Operation(summary = "Получить событие по ID")
@GetMapping("/{id}")
public ResponseEntity<EventDto> getById(@PathVariable UUID id) {
return ResponseEntity.ok(eventService.findById(id));
}
@Operation(summary = "Создать событие")
@PostMapping
public ResponseEntity<EventDto> create(
@Valid @RequestBody CreateEventRequest request
) {
EventDto created = eventService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
}
Service¶
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class EventService {
private final EventRepository eventRepository;
private final EventMapper eventMapper;
private final EventPublisher eventPublisher;
public EventDto findById(UUID id) {
return eventRepository.findById(id)
.map(eventMapper::toDto)
.orElseThrow(() -> new EventNotFoundException(id));
}
@Transactional
public EventDto create(CreateEventRequest request) {
EventEntity entity = eventMapper.toEntity(request);
entity.setStatus(EventStatus.DRAFT);
entity.setTenantId(TenantContext.getTenantId());
EventEntity saved = eventRepository.save(entity);
eventPublisher.publish(new EventCreatedEvent(saved.getId()));
return eventMapper.toDto(saved);
}
}
Repository¶
public interface EventRepository extends JpaRepository<EventEntity, UUID> {
List<EventEntity> findByTenantIdAndStatus(UUID tenantId, EventStatus status);
Optional<EventEntity> findByTenantIdAndSlug(UUID tenantId, String slug);
@Query("""
SELECT e FROM EventEntity e
WHERE e.tenantId = :tenantId
AND e.status = 'PUBLISHED'
AND e.startsAt > :now
ORDER BY e.startsAt
""")
Page<EventEntity> findUpcomingPublished(
@Param("tenantId") UUID tenantId,
@Param("now") Instant now,
Pageable pageable
);
}
Entity¶
@Entity
@Table(name = "events", schema = "event_service")
@Getter
@Setter
@NoArgsConstructor
public class EventEntity extends TenantAwareEntity {
@Column(nullable = false)
private String title;
@Column(columnDefinition = "text")
private String description;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private EventStatus status;
@Column(name = "starts_at", nullable = false)
private Instant startsAt;
@Column(name = "ends_at")
private Instant endsAt;
@Column(nullable = false)
private String slug;
@OneToMany(mappedBy = "event", cascade = CascadeType.ALL)
private List<TicketTypeEntity> ticketTypes = new ArrayList<>();
}
Code Style¶
Общие правила¶
- Google Java Style с модификациями
- 4 spaces indentation
- Max line length: 120 символов
- Constructor injection (не field injection)
Naming¶
| Элемент | Convention | Пример |
|---|---|---|
| Classes | PascalCase | EventService, CreateEventRequest |
| Methods | camelCase | findById, createEvent |
| Constants | UPPER_SNAKE | MAX_TICKET_TYPES |
| DB tables | snake_case | events, ticket_types |
| REST endpoints | kebab-case | /api/v1/ticket-types |
DTO Records¶
public record CreateEventRequest(
@NotBlank
@Size(max = 255)
String title,
String description,
@NotNull
@Future
Instant startsAt,
Instant endsAt,
@NotBlank
String timezone
) {}
Exceptions¶
// Domain-specific exceptions
public class EventNotFoundException extends AqStreamException {
public EventNotFoundException(UUID id) {
super("event_not_found", "Событие не найдено: " + id, HttpStatus.NOT_FOUND);
}
}
// Использование
throw new EventNotFoundException(eventId);
throw new RegistrationClosedException(eventId);
throw new InsufficientPermissionsException("Недостаточно прав");
Транзакции и события¶
Outbox Pattern¶
@Service
@RequiredArgsConstructor
public class EventService {
private final EventRepository eventRepository;
private final OutboxRepository outboxRepository;
@Transactional
public EventDto create(CreateEventRequest request) {
// Сохраняем entity
EventEntity saved = eventRepository.save(entity);
// Сохраняем событие в outbox (в той же транзакции)
outboxRepository.save(new OutboxMessage(
"event.created",
new EventCreatedEvent(saved.getId(), saved.getTenantId())
));
return eventMapper.toDto(saved);
}
}
Логирование¶
// Structured logging на русском
log.info("Событие создано: eventId={}, tenantId={}", event.getId(), tenantId);
log.error("Ошибка создания события: {}", e.getMessage(), e);
// НЕ логировать PII
log.info("Пользователь вошёл: email={}", email); // ❌
log.info("Пользователь вошёл: userId={}", userId); // ✅
Тестирование¶
@SpringBootTest
@Testcontainers
class EventServiceIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired
private EventService eventService;
@Test
void create_ValidRequest_ReturnsCreatedEvent() {
CreateEventRequest request = new CreateEventRequest(
"Test Event",
"Description",
Instant.now().plus(7, ChronoUnit.DAYS),
null,
"Europe/Moscow"
);
EventDto result = eventService.create(request);
assertThat(result.id()).isNotNull();
assertThat(result.title()).isEqualTo("Test Event");
assertThat(result.status()).isEqualTo(EventStatus.DRAFT);
}
}
Дальнейшее чтение¶
- API Guidelines — правила API
- Common Library — общие модули
- Service Template — шаблон сервиса