System Overview
The Extracurricular Management System (EMS) is built as a modern three-tier web application with clear separation of concerns:
┌─────────────────────────────────────────────────────────┐
│ Client Layer (React) │
│ React 19 • Tailwind CSS v4 • TanStack Query • Axios │
└────────────────────┬────────────────────────────────────┘
│ HTTP/JSON (REST API)
│
┌────────────────────▼────────────────────────────────────┐
│ Application Layer (Spring Boot) │
│ Controllers → Services → Repositories │
│ Spring Security (JWT) • Bean Validation │
└────────────────────┬────────────────────────────────────┘
│ JDBC/JPA
│
┌────────────────────▼────────────────────────────────────┐
│ Data Layer (MySQL 8.0) │
│ Single Table Inheritance • Optimistic Locking │
└─────────────────────────────────────────────────────────┘
Backend Architecture
Layered Architecture Pattern
The backend follows a strict three-layer architecture to enforce separation of concerns and maintainability:
1. Controller Layer
Responsibility: HTTP request handling, input validation, and response formatting.
Key Characteristics:
- All controllers return
ResponseEntity<Response<T>> (The Wrapper Rule)
- Use Spring Security annotations for authorization
- Delegate business logic to services
Example (EventController.java:27-45):
@RestController
@RequestMapping("/api/events")
@RequiredArgsConstructor
public class EventController {
private final EventService eventService;
@GetMapping
public ResponseEntity<Response<Page<EventDTO>>> getAllEvents(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "9") int size,
@RequestParam(required = false) String search,
@AuthenticationPrincipal CustomUserDetails userDetails) {
Pageable pageable = PageRequest.of(page, size);
Long currentUserId = userDetails != null ? userDetails.getUser().getUserID() : null;
Page<EventDTO> events = eventService.getAllApprovedEvents(pageable, search, currentUserId);
Response<Page<EventDTO>> response = Response.<Page<EventDTO>>builder()
.statusCode(HttpStatus.OK.value())
.message("Events retrieved successfully")
.data(events)
.build();
return ResponseEntity.ok(response);
}
}
Controllers never access repositories directly. All data access goes through the service layer.
2. Service Layer
Responsibility: Business logic, transaction management, and orchestration between repositories.
Key Characteristics:
- Annotated with
@Service and @Transactional where needed
- Perform validation, authorization checks, and business rules
- Interact with multiple repositories to fulfill use cases
Example pattern:
@Service
@RequiredArgsConstructor
public class EventService {
private final EventRepository eventRepository;
private final RegistrationRepository registrationRepository;
private final EmailService emailService;
@Transactional
public EventDTO createEvent(CreateEventRequest request, Long organizerID) {
// 1. Validate business rules
validateEventDates(request.getEventDate(), request.getStartTime(), request.getEndTime());
// 2. Fetch related entities
EventOrganizer organizer = findOrganizerByID(organizerID);
// 3. Create and persist
Event event = Event.builder()
.title(request.getTitle())
.organizer(organizer)
.capacity(request.getCapacity())
.build();
Event saved = eventRepository.save(event);
// 4. Trigger side effects (email, notifications)
emailService.sendNotification(organizer.getEmail(), "Event Created", "...");
return mapToDTO(saved);
}
}
3. Repository Layer
Responsibility: Data access and persistence operations.
Key Characteristics:
- Extends Spring Data JPA’s
JpaRepository<Entity, ID>
- Define custom query methods using method naming conventions or
@Query
- Keep repositories thin - complex logic belongs in services
Example:
public interface EventRepository extends JpaRepository<Event, Long> {
Page<Event> findByApprovalStatusAndStatusOrderByEventDateAsc(
ApprovalStatus approvalStatus,
EventStatus status,
Pageable pageable
);
@Query("SELECT e FROM Event e WHERE e.status = :status AND e.eventDate >= :date")
List<Event> findUpcomingEvents(@Param("status") EventStatus status, @Param("date") LocalDate date);
}
Use Spring Data JPA’s query derivation for simple queries, and @Query for complex joins or performance-critical paths.
Core Design Patterns
1. The Wrapper Rule (Standardized Responses)
All API responses follow a consistent envelope format to simplify client-side error handling and parsing.
Pattern (Response.java:15-33):
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Response<T> {
private int statusCode;
private String message;
private T data;
@Builder.Default
private LocalDateTime timestamp = LocalDateTime.now();
private List<String> errors;
}
Example JSON Response:
{
"statusCode": 200,
"message": "Event retrieved successfully",
"data": {
"eventID": 42,
"title": "Spring Workshop",
"eventDate": "2026-04-15",
"startTime": "14:00:00",
"capacity": 50
},
"timestamp": "2026-03-07T18:30:00"
}
Benefits:
- Clients can always expect the same shape
- Status codes are explicit in both HTTP status AND response body
- Error arrays provide detailed validation feedback
2. Single Table Inheritance (STI)
EMS uses Single Table Inheritance to model the user hierarchy: Student, EventOrganizer, and Administrator all inherit from a base User entity.
Implementation (User.java:15-23):
@Entity
@Table(name = "users")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "user_type", discriminatorType = DiscriminatorType.STRING)
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
public abstract class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long userID;
@Column(unique = true, nullable = false)
private String email;
@Enumerated(EnumType.STRING)
private UserRole role;
// ... other common fields
}
Child Entities:
// Student.java:12-19
@Entity
@DiscriminatorValue("STUDENT")
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
public class Student extends User {
@Column(name = "student_id", unique = true)
private String studentID; // e.g., "S2024001"
private String major;
@Column(name = "year_of_study")
private Integer yearOfStudy;
}
// EventOrganizer.java:17-27
@Entity
@DiscriminatorValue("ORGANIZER")
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
public class EventOrganizer extends User {
@Column(name = "organization_name")
private String organizationName;
@Column(name = "department_affiliation")
private String departmentAffiliation;
}
// Administrator.java:11-18
@Entity
@DiscriminatorValue("ADMIN")
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
public class Administrator extends User {
@Enumerated(EnumType.STRING)
@Column(name = "admin_level")
private AdminLevel adminLevel;
}
Database Schema:
All three types are stored in a single users table:
CREATE TABLE users (
user_id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_type VARCHAR(31) NOT NULL, -- Discriminator: 'STUDENT', 'ORGANIZER', 'ADMIN'
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(255),
last_name VARCHAR(255),
role VARCHAR(50),
-- Student-specific columns
student_id VARCHAR(255),
major VARCHAR(255),
year_of_study INT,
-- EventOrganizer-specific columns
organization_name VARCHAR(255),
department_affiliation VARCHAR(255),
-- Administrator-specific columns
admin_level VARCHAR(50),
created_at TIMESTAMP,
last_login TIMESTAMP
);
Benefits:
- Polymorphic queries:
SELECT * FROM users WHERE role = 'STUDENT'
- No complex joins for common user operations
- Spring Security can authenticate any user type with a single repository
Trade-offs:
- Nullable columns for role-specific fields
- Less normalized (more storage per row)
When querying polymorphically, always filter by user_type or use JPA’s TYPE() function to avoid casting errors:@Query("SELECT s FROM Student s WHERE TYPE(s) = Student")
List<Student> findAllStudents();
3. Optimistic Locking (Concurrency Control)
High-traffic operations like event registrations can trigger race conditions where multiple users register simultaneously, exceeding capacity.
EMS uses Optimistic Locking via JPA’s @Version annotation to detect and prevent lost updates.
Implementation (Event.java:82-84):
@Entity
@Table(name = "events")
public class Event {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "event_id")
private Long eventID;
@Column(nullable = false)
private Integer capacity;
@Column(name = "current_registrations")
@Builder.Default
private Integer currentRegistrations = 0;
// OPTIMISTIC LOCKING
@Version
private Long version;
}
How It Works:
- User A reads
Event (version = 1, currentRegistrations = 49, capacity = 50)
- User B reads
Event (version = 1, currentRegistrations = 49, capacity = 50)
- User A registers →
currentRegistrations = 50, version = 2 (UPDATE succeeds)
- User B tries to register → Hibernate checks version (expects 1, finds 2) → throws
OptimisticLockException
Service Layer Handling:
@Service
public class RegistrationService {
@Transactional
public RegistrationDTO registerForEvent(Long eventID, Long studentID) {
try {
// Fetch event (acquires current version)
Event event = eventRepository.findById(eventID)
.orElseThrow(() -> new ResourceNotFoundException("Event not found"));
// Check capacity
if (event.getCurrentRegistrations() >= event.getCapacity()) {
throw new RegistrationException("Event is full");
}
// Increment registrations
event.setCurrentRegistrations(event.getCurrentRegistrations() + 1);
eventRepository.save(event); // Version check happens here
// Create registration record
Registration registration = Registration.builder()
.event(event)
.student(findStudentByID(studentID))
.registrationDate(LocalDateTime.now())
.build();
return mapToDTO(registrationRepository.save(registration));
} catch (OptimisticLockException e) {
// Retry or inform user
throw new RegistrationException("Another user registered simultaneously. Please try again.");
}
}
}
Always fetch the latest entity before modifying versioned records. Detached entities will cause OptimisticLockException even without conflicts.
4. Adapter Pattern (Email Notifications)
The Adapter Pattern decouples the notification system from specific email providers, enabling easy swaps (e.g., Gmail → SendGrid → AWS SES) without touching service logic.
Interface (EmailService.java:3-5):
public interface EmailService {
void sendNotification(String to, String subject, String body);
}
Adapter Implementation (OutlookEmailAdapter.java:11-36):
@Service // Active implementation
@RequiredArgsConstructor
@Slf4j
public class OutlookEmailAdapter implements EmailService {
private final JavaMailSender mailSender;
@Override
@Async // Background processing
public void sendNotification(String to, String subject, String body) {
try {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom("[email protected]"); // Must match spring.mail.username
message.setTo(to);
message.setSubject("[EMS] " + subject);
message.setText(body);
mailSender.send(message);
log.info("MAIL SUCCESS: Email delivered to {}", to);
} catch (Exception e) {
log.error("MAIL FAILURE: Delivery failed for {}. Error: {}", to, e.getMessage());
}
}
}
Service Layer Usage:
@Service
@RequiredArgsConstructor
public class ProposalService {
private final EmailService emailService; // Depends on interface, not implementation
public void approveProposal(Long proposalID) {
Proposal proposal = findProposalByID(proposalID);
proposal.setApprovalStatus(ApprovalStatus.APPROVED);
proposalRepository.save(proposal);
// Send notification (doesn't care which adapter is active)
emailService.sendNotification(
proposal.getOrganizer().getEmail(),
"Proposal Approved",
"Your event proposal has been approved!"
);
}
}
Swapping Adapters:
To switch from Gmail to SendGrid:
- Create
SendGridEmailAdapter.java implementing EmailService
- Annotate it with
@Service or @Primary
- Comment out
@Service on OutlookEmailAdapter
- Update
application.properties with SendGrid credentials
No service code changes required.
The @Async annotation enables background email sending, preventing API slowdowns from SMTP latency.
Frontend Architecture
Technology Stack
- React 19 - Latest React with automatic batching and concurrent features
- Tailwind CSS v4 - Utility-first styling with JIT compilation
- TanStack Query v5 - Server state management, caching, and synchronization
- React Router v7 - Client-side routing
- Axios - HTTP client with interceptors for auth tokens
- Lucide Icons - Lightweight icon library
- Vite - Fast build tool and dev server
Key Patterns
Safe Hydration (safeParse.js)
The frontend uses defensive parsing to prevent crashes from malformed localStorage data (e.g., after schema changes or browser extensions).
Implementation (safeParse.js:1-30):
export const safeGetItem = (key) => {
try {
const raw = localStorage?.getItem?.(key);
if (!raw || raw === 'undefined') return null;
return raw;
} catch (err) {
console.warn('safeGetItem error', err);
return null;
}
};
export const safeParseUser = () => {
const raw = safeGetItem('user');
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
// Handle legacy schema: id → userID
if (parsed && parsed.id && !parsed.userID) {
parsed.userID = parsed.id;
}
return {
...parsed,
firstName: parsed?.firstName || 'User',
lastName: parsed?.lastName || '',
role: parsed?.role || 'GUEST',
};
} catch (err) {
console.warn('safeParseUser error', err);
return null;
}
};
Benefits:
- Graceful degradation on parse errors
- Backward compatibility during schema migrations
- Prevents white-screen errors from corrupted session data
Always use safeParseUser() instead of directly reading localStorage.getItem('user') to avoid runtime crashes.
TanStack Query for Server State
EMS uses TanStack Query to manage server state (events, proposals, registrations) separately from local UI state.
Example:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
const useEvents = (page = 0, search = '') => {
return useQuery({
queryKey: ['events', page, search],
queryFn: async () => {
const { data } = await axios.get('/api/events', {
params: { page, size: 9, search },
});
return data.data; // Unwrap Response<T>.data
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
const useRegisterForEvent = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (eventID) => axios.post(`/api/registrations/${eventID}`),
onSuccess: () => {
// Invalidate events cache to refetch updated registration counts
queryClient.invalidateQueries({ queryKey: ['events'] });
},
});
};
Benefits:
- Automatic caching and deduplication
- Background refetching for stale data
- Optimistic updates for instant UI feedback
Data Conventions
Identity Naming (camelCaseID)
All identifiers follow the camelCaseID convention for consistency:
// Backend entities
private Long userID; // NOT user_id or userId
private Long eventID; // NOT event_id
private Long proposalID; // NOT proposal_id
// API responses
{
"eventID": 42,
"organizerID": 7,
"registrationID": 123
}
Temporal Data Handling
EMS strictly separates dates and times to avoid time zone ambiguity.
Backend (Event.java:35-42):
@Column(name = "event_date", nullable = false)
private LocalDate eventDate; // e.g., 2026-04-15
@Column(name = "start_time", nullable = false)
private LocalTime startTime; // e.g., 14:00:00
@Column(name = "end_time", nullable = false)
private LocalTime endTime; // e.g., 16:30:00
JSON Serialization:
{
"eventDate": "2026-04-15",
"startTime": "14:00:00",
"endTime": "16:30:00"
}
Never use LocalDateTime or Instant for event scheduling. Time zones are implicit and can cause DST bugs. Store dates and times separately.
Database Indexing
Key indexes for high-traffic queries:
-- Users table
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_role ON users(role);
-- Events table
CREATE INDEX idx_events_status_date ON events(status, event_date);
CREATE INDEX idx_events_approval_status ON events(approval_status);
-- Registrations table
CREATE INDEX idx_registrations_student ON registrations(student_id);
CREATE INDEX idx_registrations_event ON registrations(event_id);
Connection Pooling
HikariCP configuration (application.properties.example:25-31):
spring.datasource.hikari.maximum-pool-size=50
spring.datasource.hikari.minimum-idle=20
spring.datasource.hikari.connection-timeout=20000
spring.datasource.hikari.idle-timeout=300000
spring.datasource.hikari.max-lifetime=600000
spring.datasource.hikari.leak-detection-threshold=60000
Tuning Guide:
maximum-pool-size=50: Handles ~50 concurrent database operations
minimum-idle=20: Keeps 20 connections warm for low-latency requests
leak-detection-threshold=60000: Logs warnings for connections held > 60s
Monitor connection pool metrics via Spring Boot Actuator at /actuator/metrics/hikaricp.*
Security Architecture
JWT-Based Authentication
EMS uses stateless JWT tokens for authentication:
- User logs in with credentials
- Backend validates and issues a JWT signed with
secreteJwtString
- Client stores token in localStorage
- All subsequent requests include
Authorization: Bearer <token> header
- Backend verifies signature and extracts user claims
Token Structure:
{
"sub": "[email protected]",
"userID": 42,
"role": "STUDENT",
"iat": 1709832000,
"exp": 1709918400
}
Role-Based Access Control (RBAC)
Controllers use Spring Security annotations for authorization:
@PreAuthorize("hasRole('ORGANIZER')")
@PostMapping("/proposals")
public ResponseEntity<Response<ProposalDTO>> createProposal(
@Valid @RequestBody CreateProposalRequest request,
@AuthenticationPrincipal CustomUserDetails userDetails) {
// Only ORGANIZER role can create proposals
}
@PreAuthorize("hasRole('ADMIN')")
@PutMapping("/proposals/{proposalID}/approve")
public ResponseEntity<Response<Void>> approveProposal(@PathVariable Long proposalID) {
// Only ADMIN role can approve proposals
}
Always pair @PreAuthorize with @AuthenticationPrincipal to verify the JWT was issued to the correct user.
Testing Strategy
Integration Tests
Prefer integration tests over unit tests to validate the full stack:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Transactional
class EventControllerIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private EventRepository eventRepository;
@Test
void shouldRegisterForEventAndDecrementCapacity() {
// Given: An event with capacity 50
Event event = createTestEvent(50);
// When: 5 students register
for (int i = 0; i < 5; i++) {
restTemplate.postForEntity("/api/registrations/" + event.getEventID(), null, Void.class);
}
// Then: Capacity is reduced to 45
Event updated = eventRepository.findById(event.getEventID()).orElseThrow();
assertThat(updated.getCurrentRegistrations()).isEqualTo(5);
}
}
SMTP Mocking
Use GreenMail to mock SMTP in tests:
@SpringBootTest
@ExtendWith(GreenMailExtension.class)
class EmailNotificationTest {
@GreenMail
private GreenMailExtension greenMail;
@Autowired
private EmailService emailService;
@Test
void shouldSendApprovalEmail() {
emailService.sendNotification("[email protected]", "Approved", "Your event is approved");
MimeMessage[] emails = greenMail.getReceivedMessages();
assertThat(emails).hasSize(1);
assertThat(emails[0].getSubject()).isEqualTo("[EMS] Approved");
}
}
Deployment Considerations
Environment-Specific Configuration
Use Spring profiles for multi-environment deployments:
# Development
java -jar ems-backend.jar --spring.profiles.active=dev
# Production
java -jar ems-backend.jar --spring.profiles.active=prod
application-prod.properties:
spring.jpa.hibernate.ddl-auto=validate # Never auto-update in prod
logging.level.com.ems.backend=INFO
spring.datasource.hikari.maximum-pool-size=100
File Upload Limits
Proposal documents use multipart uploads. Adjust limits for production (application.properties.example:13-14):
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=50MB
Ensure your reverse proxy (Nginx, AWS ALB) also allows these file sizes. Nginx defaults to 1MB.
Further Reading