Backend Services
- 1. Overview
- 2. admin-service
- 2.1. Technology Stack
- 2.2. Architecture
- 2.3. Dependencies
- 2.4. Project Structure
- 2.5. API Endpoints
- 2.6. REST Controller Example
- 2.7. Service Layer Example
- 2.8. DTO Mapping with MapStruct
- 2.9. Error Handling
- 2.10. Security Configuration
- 2.11. API Documentation
- 2.12. Configuration
- 2.13. Deployment
- 2.14. Testing
- 3. Future Services
- 4. Related Documentation
1. Overview
The backend services layer provides REST API endpoints for business logic and data access. Currently, the system has a single backend service component that handles all API functionality.
2. admin-service
The primary REST API service providing endpoints for event management, membership administration, and system operations.
Repository: https://github.com/christhonie/admin-service
Maven Coordinates:
<groupId>za.co.idealogic</groupId>
<artifactId>admin-service</artifactId>
<version>${revision}</version>
<packaging>jar</packaging>
2.1. Technology Stack
Core Framework:
-
Spring Boot 2.7+
-
Spring Web MVC
-
Spring Data JPA
-
Spring Security
Additional Libraries:
-
Jackson for JSON serialization
-
Lombok for reducing boilerplate
-
MapStruct for DTO mapping
-
Bean Validation (JSR-380)
-
Springdoc OpenAPI for API documentation
2.3. Dependencies
<dependencies>
<!-- Parent POM -->
<parent>
<groupId>za.co.idealogic</groupId>
<artifactId>event</artifactId>
<version>${revision}</version>
</parent>
<!-- Internal Dependencies -->
<dependency>
<groupId>za.co.idealogic</groupId>
<artifactId>event-common</artifactId>
</dependency>
<dependency>
<groupId>za.co.idealogic</groupId>
<artifactId>event-database</artifactId>
</dependency>
<dependency>
<groupId>za.co.idealogic</groupId>
<artifactId>wordpress-database</artifactId>
</dependency>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Database -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Utilities -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
</dependency>
<!-- API Documentation -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
</dependency>
</dependencies>
2.4. Project Structure
admin-service/
├── src/main/java/za/co/idealogic/event/
│ ├── AdminServiceApplication.java
│ ├── config/
│ │ ├── SecurityConfig.java
│ │ ├── JpaConfig.java
│ │ ├── WebConfig.java
│ │ └── OpenApiConfig.java
│ ├── controller/
│ │ ├── EventController.java
│ │ ├── ParticipantController.java
│ │ ├── RaceController.java
│ │ ├── MembershipController.java
│ │ └── FinancialController.java
│ ├── service/
│ │ ├── EventService.java
│ │ ├── ParticipantService.java
│ │ ├── RaceService.java
│ │ ├── MembershipService.java
│ │ └── FinancialService.java
│ ├── dto/
│ │ ├── request/
│ │ │ ├── EventCreateRequest.java
│ │ │ ├── EventUpdateRequest.java
│ │ │ └── ParticipantCreateRequest.java
│ │ └── response/
│ │ ├── EventResponse.java
│ │ ├── ParticipantResponse.java
│ │ └── PageResponse.java
│ ├── mapper/
│ │ ├── EventMapper.java
│ │ ├── ParticipantMapper.java
│ │ └── MembershipMapper.java
│ ├── security/
│ │ ├── JwtTokenProvider.java
│ │ ├── JwtAuthenticationFilter.java
│ │ ├── OrganisationSecurityFilter.java
│ │ └── SecurityContextHolder.java
│ └── exception/
│ ├── GlobalExceptionHandler.java
│ ├── ResourceNotFoundException.java
│ └── BusinessValidationException.java
├── src/main/resources/
│ ├── application.yml
│ ├── application-dev.yml
│ ├── application-prod.yml
│ └── db/migration/
│ └── (Flyway migrations)
└── src/test/java/
└── (Test classes)
2.5. API Endpoints
2.5.1. Event Management API
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/events |
List events with pagination and filtering |
GET |
/api/events/{id} |
Get event details |
POST |
/api/events |
Create new event |
PUT |
/api/events/{id} |
Update event |
DELETE |
/api/events/{id} |
Delete event (soft delete) |
GET |
/api/events/{id}/participants |
List event participants |
GET |
/api/events/{id}/races |
List event races |
POST |
/api/events/{id}/races/generate |
Generate races from race matrix |
2.5.2. Participant Management API
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/participants |
List participants |
GET |
/api/participants/{id} |
Get participant details |
POST |
/api/participants |
Create participant entry |
PUT |
/api/participants/{id} |
Update participant |
POST |
/api/participants/{id}/races |
Register participant for races |
GET |
/api/participants/{id}/results |
Get participant results |
2.5.3. Race Management API
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/races |
List races |
GET |
/api/races/{id} |
Get race details |
PUT |
/api/races/{id} |
Update race configuration |
GET |
/api/races/{id}/start-groups |
List start groups for race |
POST |
/api/races/{id}/start-groups |
Create start group |
GET |
/api/races/{id}/results |
Get race results |
2.6. REST Controller Example
@RestController
@RequestMapping("/api/events")
@RequiredArgsConstructor
@Validated
public class EventController {
private final EventService eventService;
@GetMapping
public PageResponse<EventResponse> getEvents(
@RequestParam(required = false) Long organisationId,
@RequestParam(required = false) String status,
@RequestParam(required = false) LocalDate startDate,
@RequestParam(required = false) LocalDate endDate,
@PageableDefault(size = 20, sort = "startDate", direction = Sort.Direction.DESC)
Pageable pageable) {
return eventService.getEvents(organisationId, status, startDate, endDate, pageable);
}
@GetMapping("/{id}")
public EventResponse getEvent(@PathVariable Long id) {
return eventService.getEvent(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public EventResponse createEvent(@Valid @RequestBody EventCreateRequest request) {
return eventService.createEvent(request);
}
@PutMapping("/{id}")
public EventResponse updateEvent(
@PathVariable Long id,
@Valid @RequestBody EventUpdateRequest request) {
return eventService.updateEvent(id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteEvent(@PathVariable Long id) {
eventService.deleteEvent(id);
}
@PostMapping("/{id}/races/generate")
public List<RaceResponse> generateRaces(@PathVariable Long id) {
return eventService.generateRaces(id);
}
}
2.7. Service Layer Example
@Service
@Transactional
@RequiredArgsConstructor
public class EventService {
private final EventRepository eventRepository;
private final EventCategoryRepository categoryRepository;
private final EventRaceTypeRepository raceTypeRepository;
private final RaceRepository raceRepository;
private final EventMapper eventMapper;
private final SecurityContextHolder securityContext;
public PageResponse<EventResponse> getEvents(
Long organisationId, String status, LocalDate startDate, LocalDate endDate,
Pageable pageable) {
// Apply organisation security filter
Long orgId = organisationId != null ? organisationId : securityContext.getOrganisationId();
// Build specification for dynamic filtering
Specification<Event> spec = EventSpecification.builder()
.organisationId(orgId)
.status(status)
.startDateFrom(startDate)
.endDateTo(endDate)
.build();
Page<Event> events = eventRepository.findAll(spec, pageable);
return PageResponse.of(
events.map(eventMapper::toResponse)
);
}
public EventResponse getEvent(Long id) {
Event event = eventRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Event not found: " + id));
// Verify organisation access
securityContext.verifyOrganisationAccess(event.getOrganisation().getId());
return eventMapper.toResponse(event);
}
public EventResponse createEvent(EventCreateRequest request) {
// Validate business rules
validateEventDates(request.getStartDate(), request.getEndDate());
// Map request to entity
Event event = eventMapper.toEntity(request);
event.setOrganisation(securityContext.getCurrentOrganisation());
event.setStatus(EventStatus.DRAFT);
// Save event
event = eventRepository.save(event);
return eventMapper.toResponse(event);
}
public List<RaceResponse> generateRaces(Long eventId) {
Event event = getEventEntity(eventId);
// Get event categories and race types
List<EventCategory> categories = categoryRepository.findByEventId(eventId);
List<EventRaceType> raceTypes = raceTypeRepository.findByEventId(eventId);
// Generate race matrix (all combinations)
List<Race> races = new ArrayList<>();
for (EventCategory category : categories) {
for (EventRaceType raceType : raceTypes) {
// Check if race already exists
Optional<Race> existing = raceRepository
.findByEventRaceTypeAndEventCategory(raceType, category);
if (existing.isEmpty()) {
Race race = new Race();
race.setEventRaceType(raceType);
race.setEventCategory(category);
race.setName(raceType.getName() + " - " + category.getName());
race.setActive(true);
races.add(race);
}
}
}
// Save generated races
races = raceRepository.saveAll(races);
return races.stream()
.map(raceMapper::toResponse)
.collect(Collectors.toList());
}
private void validateEventDates(LocalDate startDate, LocalDate endDate) {
if (startDate.isAfter(endDate)) {
throw new BusinessValidationException(
"Event start date must be before or equal to end date"
);
}
if (startDate.isBefore(LocalDate.now().minusDays(30))) {
throw new BusinessValidationException(
"Event start date cannot be more than 30 days in the past"
);
}
}
}
2.8. DTO Mapping with MapStruct
@Mapper(componentModel = "spring", uses = {OrganisationMapper.class})
public interface EventMapper {
@Mapping(target = "organisationId", source = "organisation.id")
@Mapping(target = "organisationName", source = "organisation.name")
@Mapping(target = "categoryCount", expression = "java(event.getCategories().size())")
@Mapping(target = "raceCount", expression = "java(event.getRaces().size())")
EventResponse toResponse(Event event);
@Mapping(target = "id", ignore = true)
@Mapping(target = "organisation", ignore = true)
@Mapping(target = "status", ignore = true)
@Mapping(target = "createdDate", ignore = true)
@Mapping(target = "createdBy", ignore = true)
@Mapping(target = "lastModifiedDate", ignore = true)
@Mapping(target = "lastModifiedBy", ignore = true)
@Mapping(target = "version", ignore = true)
Event toEntity(EventCreateRequest request);
List<EventResponse> toResponseList(List<Event> events);
}
2.9. Error Handling
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
log.warn("Resource not found: {}", ex.getMessage());
ErrorResponse error = ErrorResponse.builder()
.code("RESOURCE_NOT_FOUND")
.message(ex.getMessage())
.timestamp(Instant.now())
.build();
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(BusinessValidationException.class)
public ResponseEntity<ErrorResponse> handleBusinessValidation(BusinessValidationException ex) {
log.warn("Business validation failed: {}", ex.getMessage());
ErrorResponse error = ErrorResponse.builder()
.code(ex.getCode())
.message(ex.getMessage())
.timestamp(Instant.now())
.build();
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationErrors(MethodArgumentNotValidException ex) {
List<String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.toList());
ErrorResponse error = ErrorResponse.builder()
.code("VALIDATION_ERROR")
.message("Request validation failed")
.details(errors)
.timestamp(Instant.now())
.build();
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException ex) {
log.warn("Access denied: {}", ex.getMessage());
ErrorResponse error = ErrorResponse.builder()
.code("ACCESS_DENIED")
.message("You do not have permission to access this resource")
.timestamp(Instant.now())
.build();
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
log.error("Unexpected error", ex);
ErrorResponse error = ErrorResponse.builder()
.code("INTERNAL_ERROR")
.message("An unexpected error occurred")
.timestamp(Instant.now())
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}
2.10. Security Configuration
See Security Architecture for detailed security implementation including:
-
JWT token-based authentication
-
Organisation-scoped security
-
Person-level security
-
Role-based access control
2.11. API Documentation
API documentation is automatically generated using Springdoc OpenAPI:
Access:
-
Swagger UI:
http://localhost:8080/swagger-ui.html -
OpenAPI JSON:
http://localhost:8080/v3/api-docs
Configuration:
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Event Management API")
.version("1.2.0")
.description("REST API for Event and Membership Administration System")
.contact(new Contact()
.name("API Support")
.email("[email protected]")))
.addSecurityItem(new SecurityRequirement().addList("bearer-jwt"))
.components(new Components()
.addSecuritySchemes("bearer-jwt", new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")));
}
}
2.12. Configuration
application.yml:
server:
port: 8080
servlet:
context-path: /
spring:
application:
name: admin-service
datasource:
url: jdbc:mysql://localhost:3306/event_db
username: ${DB_USERNAME:root}
password: ${DB_PASSWORD:password}
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: validate
show-sql: false
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.MySQL8Dialect
jdbc:
batch_size: 20
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
logging:
level:
za.co.idealogic: DEBUG
org.springframework.web: INFO
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
springdoc:
swagger-ui:
path: /swagger-ui.html
api-docs:
path: /v3/api-docs
2.13. Deployment
The service is packaged as an executable JAR:
# Build
mvn clean package
# Run
java -jar target/admin-service-1.2.0.jar
# Run with profile
java -jar target/admin-service-1.2.0.jar --spring.profiles.active=prod
See Deployment Documentation for CI/CD and production deployment details.
2.14. Testing
Unit Tests:
@ExtendWith(MockitoExtension.class)
class EventServiceTest {
@Mock
private EventRepository eventRepository;
@Mock
private SecurityContextHolder securityContext;
@InjectMocks
private EventService eventService;
@Test
void shouldCreateEvent() {
// Given
EventCreateRequest request = new EventCreateRequest();
request.setName("Test Event");
request.setStartDate(LocalDate.now().plusDays(30));
request.setEndDate(LocalDate.now().plusDays(31));
Organisation org = new Organisation();
org.setId(1L);
when(securityContext.getCurrentOrganisation()).thenReturn(org);
when(eventRepository.save(any(Event.class))).thenAnswer(i -> i.getArgument(0));
// When
EventResponse response = eventService.createEvent(request);
// Then
assertThat(response.getName()).isEqualTo("Test Event");
verify(eventRepository).save(any(Event.class));
}
}
Integration Tests:
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class EventControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
@WithMockUser(authorities = {"ROLE_ADMIN"})
void shouldCreateEvent() throws Exception {
EventCreateRequest request = new EventCreateRequest();
request.setName("Integration Test Event");
request.setStartDate(LocalDate.now().plusDays(30));
request.setEndDate(LocalDate.now().plusDays(31));
mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("Integration Test Event"));
}
}
3. Future Services
The architecture supports adding additional backend services as the system grows:
Planned Services:
-
notification-service - Email and SMS notifications
-
reporting-service - Report generation and analytics
-
timing-service - Real-time race timing and results
-
payment-service - Payment processing integration
Each service would follow the same architectural patterns as admin-service.