Backend Services

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.

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.2. Architecture

admin-service-architecture

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.5.4. Membership API

Method Endpoint Description

GET

/api/memberships

List memberships

POST

/api/memberships

Create membership

GET

/api/memberships/{id}

Get membership details

PUT

/api/memberships/{id}/renew

Renew membership

2.5.5. Financial API

Method Endpoint Description

GET

/api/orders

List orders

GET

/api/orders/{id}

Get order details

POST

/api/orders

Create order

POST

/api/orders/{id}/payment

Process payment

GET

/api/financial/reports

Generate financial reports

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:

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.