Backend Architecture
This document outlines the core backend architecture for the BATbern Event Management Platform, including service patterns, authentication, authorization, and error handling strategies.
This document outlines the core backend architecture for the BATbern Event Management Platform, including service patterns, authentication, authorization, and error handling strategies.
Related Documentation
For detailed information on specific subsystems, see:
- Workflow State Machines - Event workflow, speaker coordination, slot assignment, quality review, and overflow voting
- User Lifecycle Sync Patterns - Unidirectional Cognito β PostgreSQL sync with Lambda triggers
- Testing Strategy - Testcontainers PostgreSQL setup, integration and unit testing patterns
- Notification System - Real-time notifications, escalation rules, and multi-channel delivery
Service Architecture Pattern
services/{domain-service}/
βββ src/main/java/ch/batbern/{domain}/
β βββ controller/ # REST API controllers
β βββ service/ # Business logic layer
β βββ repository/ # Data access layer
β βββ domain/ # Domain models and entities
β βββ dto/ # Data transfer objects
β βββ exception/ # Custom exceptions
β βββ security/ # Security components
βββ src/main/resources/
β βββ application.yml # Configuration
β βββ db/migration/ # Flyway migrations
βββ build.gradle # Build configuration
Authentication and Authorization
JWT Authentication Flow
Role-Based Security Configuration
Important: Domain services do not validate JWTs themselves. JWT signature verification is performed exclusively by the API Gateway. Domain services trust all requests forwarded by the gateway and do not call
jwtDecoder()or validate token signatures. Role-based access control at the service level is enforced via method-level annotations (@PreAuthorize) where needed, using the user context header injected by the gateway.
Public Endpoints (no authentication required)
The following endpoints are accessible without any token at the domain-service level:
| Pattern | Notes |
|---|---|
/actuator/health, /actuator/info |
Health probes |
/swagger-ui/**, /v3/api-docs/** |
API documentation |
GET /api/v1/events/** |
Event details, including /current |
GET /api/v1/events/{code}/sessions/** |
Session listings |
GET /api/v1/events/{code}/speakers/** |
Speaker listings (read-only) |
POST /api/v1/events/{code}/registrations |
Attendee registration creation |
POST /api/v1/events/{code}/registrations/confirm |
Registration confirmation |
VPC-Internal Endpoints (no JWT required):
Some endpoints are designed for cross-service communication within the VPC only. These are protected by VpcInternalAuthorizationManager (which validates the request originates from within the VPC) rather than JWT-based role checks. Example:
| Pattern | Service | Notes |
|---|---|---|
GET /api/v1/users/by-company |
Company-User Management | Used by partner-coordination-service to fetch contacts without requiring an organizer JWT |
This pattern avoids the need to forward end-user JWTs for internal service-to-service calls that donβt require user-level authorization.
Role-gated endpoints (e.g. POST /api/v1/events, organizer-only mutations) are enforced at the API Gateway layer before requests reach domain services.
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// Health & docs β always public
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
// Public read endpoints β no auth required
.requestMatchers(HttpMethod.GET, "/api/v1/events/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/events/*/sessions/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/events/*/speakers/**").permitAll()
// Public write endpoints β registration flow
.requestMatchers(HttpMethod.POST, "/api/v1/events/*/registrations").permitAll()
.requestMatchers(HttpMethod.POST, "/api/v1/events/*/registrations/confirm").permitAll()
// Everything else: trust the gateway-injected user context
.anyRequest().permitAll()
)
.build();
// No oauth2ResourceServer / jwtDecoder β JWT validation is the gateway's responsibility
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(this::extractAuthorities);
return converter;
}
private Collection<GrantedAuthority> extractAuthorities(Jwt jwt) {
// Extract custom:role claim from JWT (populated by PreTokenGeneration Lambda)
String rolesString = jwt.getClaimAsString("custom:role");
if (rolesString == null || rolesString.isEmpty()) {
return Collections.emptyList();
}
// Split comma-separated roles and map to Spring Security authorities
// "ATTENDEE,SPEAKER" β [ROLE_ATTENDEE, ROLE_SPEAKER]
return Arrays.stream(rolesString.split(","))
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.trim()))
.collect(Collectors.toList());
}
}
User Context Management
@Component
public class SecurityContextHelper {
public UserContext getCurrentUser() {
// Implementation details - see source code
}
public boolean hasRole(String role) {
// Implementation details - see source code
}
public boolean isOrganizer() {
// Implementation details - see source code
}
public boolean canAccessCompany(String companyId) {
// Implementation details - see source code
}
}
Speaker authentication (ADR-009)
Speakers are full Cognito users with the SPEAKER role. Provisioning is wired into the speaker workflow state machine β not into a separate auth flow.
Provisioning lifecycle (Story 11.E.2 two-endpoint design β Resolved Q#1 Variant B):
-
CONTACTED β READYtransition (SpeakerWorkflowService.transition()side-effect hook): the backend callsUserApiClient.provisionUserWithRole(email, firstName, lastName, SPEAKER). The company-user-management-service performs, in order:cognito-idp:AdminCreateUserwithMessageAction=SUPPRESSand a throwaway temporary password. The user is created in Cognito with statusFORCE_CHANGE_PASSWORD.- SPEAKER role grant in
role_assignments(database β NOT Cognito groups, per CLAUDE.md βAuthentication & Rolesβ). INSERT INTO user_profiles(idempotent on existing user).- Returns
{ username, created }to the caller. The throwaway temporary password is never returned (thetemporaryPasswordfield was removed fromProvisionUserResponsein Story 11.E.2) and is discarded from CUMS memory.
Re-calling
provisionUserWithRolefor an already-provisioned user is idempotent and skips the Cognito call entirely (the Cognito user already exists). -
READY β INVITEDtransition: EMSβsrunInvitedHookcalls the new sibling endpointPOST /api/v1/users/{username}/issue-invitation-credentials. CUMS branches on the Cognito userβs current status (viaAdminGetUser):FORCE_CHANGE_PASSWORD/RESET_REQUIRED/UNCONFIRMEDβ generate a fresh temp password, callAdminSetUserPassword(Permanent=false), return{ action: "FRESH_TEMP_PASSWORD", temporaryPassword: "..." }.CONFIRMEDβ no Cognito mutation; return{ action: "USE_EXISTING_PASSWORD", temporaryPassword: null }.ARCHIVED/COMPROMISEDβ return HTTP 422 (UnprocessableInvitationStateException).
The invitation-email service then sends a templated HTML email containing the speaker portal login URL + the speakerβs email as the Cognito username + either the temporary password block or the βuse existing password / Forgot passwordβ block (selected by the
actiondiscriminator). Templates ship inde+enonly perCLAUDE.mdΒ§βLocalization β Email Templates: DE + EN Only; UI i18n: All 10 Localesβ; other locales fall back toen. HTML-only (no.txtparity) per Story 11.E.2 Resolved Q#3. -
First login: speaker enters email + temporary password on the standard Cognito hosted/SDK login page. Cognito challenges them with
NEW_PASSWORD_REQUIRED; the speaker sets a new password. From then on they are a regular Cognito user βcustom:roleJWT claim includesSPEAKER(populated by the PreTokenGeneration Lambda fromrole_assignments). -
Portal access: every
/api/v1/speaker-portal/**endpoint is@PreAuthorize("hasRole('SPEAKER')"). The speaker-portal frontend (web-frontend/src/pages/speaker/**) reads the Cognito session viauseAuth()β same pattern as organizer/partner pages β and calls backend endpoints with a Bearer token.
Components NOT present (deleted by ADR-009 Decision 3 / Epic 11 Phase F):
MagicLinkService,SpeakerPortalTokenController,SpeakerMagicLoginController,JwtConfig(speaker-side).- The
magic_link_tokenstable. - The
speaker_jwtHTTP-only cookie. ?token=and?jwt=URL parsing inpages/speaker/**.
No Lambda triggers are required. Standard Cognito invitation flow covers the entire path β no DefineAuthChallenge / CreateAuthChallenge / VerifyAuthChallengeResponse Lambdas; no Custom Auth flow.
See 06b-user-lifecycle-sync.md (βPattern N: Speaker Provisioning at CONTACTED β READYβ) for the cross-service sequence diagram.
Error Handling Strategy
Error Flow
Error Response Format
interface ApiError {
error: {
code: string;
message: string;
details?: Record<string, any>;
timestamp: string;
requestId: string;
path: string;
severity: string;
};
}
Comprehensive Exception Hierarchy
// Base domain exception
public abstract class BATbernException extends RuntimeException {
// Implementation details - see source code
}
// Domain-specific exceptions
@ResponseStatus(HttpStatus.NOT_FOUND)
public class EventNotFoundException extends BATbernException {
// Implementation details - see source code
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
public class InvalidEventStateException extends BATbernException {
// Implementation details - see source code
}
@ResponseStatus(HttpStatus.CONFLICT)
public class SpeakerAlreadyInvitedException extends BATbernException {
// Implementation details - see source code
}
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
public class BusinessValidationException extends BATbernException {
// Implementation details - see source code
}
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
public class RateLimitExceededException extends BATbernException {
// Implementation details - see source code
}
Global Exception Handler
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
private final ErrorMessageResolver messageResolver;
private final MetricRegistry metricRegistry;
@ExceptionHandler(BATbernException.class)
public ResponseEntity<ErrorResponse> handleBATbernException(BATbernException ex, HttpServletRequest request) {
// Implementation details - see source code
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException ex, HttpServletRequest request) {
// Implementation details - see source code
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> handleDataIntegrityViolation(DataIntegrityViolationException ex, HttpServletRequest request) {
// Implementation details - see source code
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception ex, HttpServletRequest request) {
// Implementation details - see source code
}
}
Circuit Breaker Pattern Implementation
The platform uses Resilience4j circuit breakers (not a bespoke CircuitBreakerService). Configuration is rate-based, not count-based:
# application-shared.yml (shared across services)
resilience4j:
circuitbreaker:
instances:
eventBridgePublisher:
failureRateThreshold: 60 # open after 60% failure rate (not a fixed count)
waitDurationInOpenState: 10s # 10 seconds in open state (not 60 000 ms)
permittedNumberOfCallsInHalfOpenState: 5
slidingWindowSize: 10
minimumNumberOfCalls: 5
// Usage via Resilience4j annotation
@CircuitBreaker(name = "eventBridgePublisher", fallbackMethod = "storeForRetry")
public void publishEvent(DomainEvent event) {
// Implementation details - see source code
}
private void storeForRetry(DomainEvent event, Exception ex) {
// Store event for later retry
failedEventStore.store(event);
}
Retry Mechanism with Exponential Backoff
@Component
public class RetryService {
@Retryable(
value = {TransientException.class, TemporaryUnavailableException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2, maxDelay = 10000)
)
public void executeWithRetry(Runnable operation) {
// Implementation details - see source code
}
@Recover
public void recover(Exception ex) {
// Implementation details - see source code
}
}
Request Correlation and Context
@Component
@Slf4j
public class RequestCorrelationFilter implements Filter {
private static final String CORRELATION_ID_HEADER = "X-Correlation-ID";
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
// Implementation details - see source code
}
}
public class RequestContext {
private static final ThreadLocal<String> correlationId = new ThreadLocal<>();
private static final ThreadLocal<UserContext> userContext = new ThreadLocal<>();
public static String getCurrentRequestId() {
// Implementation details - see source code
}
public static void setCorrelationId(String id) {
// Implementation details - see source code
}
public static UserContext getCurrentUser() {
// Implementation details - see source code
}
public static void setCurrentUser(UserContext user) {
// Implementation details - see source code
}
public static void clear() {
// Implementation details - see source code
}
}
Service Communication Patterns
Domain Events
@Component
@Slf4j
public class DomainEventPublisher {
private final ApplicationEventPublisher eventPublisher;
private final EventBridge eventBridge;
public void publishEvent(DomainEvent event) {
// Publish locally for same-service subscribers
eventPublisher.publishEvent(event);
// Publish to EventBridge for cross-service communication
try {
eventBridge.publishEvent(event);
log.debug("Published domain event: {} with ID: {}", event.getEventType(), event.getId());
} catch (Exception ex) {
log.error("Failed to publish domain event to EventBridge: {}", event.getId(), ex);
// Store for retry
failedEventStore.store(event);
}
}
}
// Event listeners
@EventListener
@Async
public void handleSpeakerInvited(SpeakerInvitedEvent event) {
log.info("Processing speaker invitation for speaker {} to event {}",
event.getSpeakerId(), event.getEventId());
// Implementation details - see source code
}
Data Validation and Business Rules
@Component
public class EventBusinessRules {
public void validateEventCreation(CreateEventRequest request) {
// Business rule: Event date must be at least 30 days in the future
if (request.getEventDate().isBefore(LocalDateTime.now().plusDays(30))) {
throw new BusinessValidationException(
"eventDate",
"Event date must be at least 30 days in the future",
request.getEventDate()
);
}
// Business rule: Only one event per quarter
if (eventRepository.existsByQuarter(getQuarter(request.getEventDate()))) {
throw new BusinessValidationException(
"eventDate",
"Only one event is allowed per quarter",
getQuarter(request.getEventDate())
);
}
}
public void validateSpeakerInvitation(String speakerId, String eventId) {
// Business rule: Speaker cannot be invited to multiple sessions in same time slot
List<Session> conflictingSessions = sessionRepository.findConflictingSessions(speakerId, eventId);
if (!conflictingSessions.isEmpty()) {
throw new BusinessValidationException(
"speakerId",
"Speaker has conflicting sessions",
Map.of("conflictingSessions", conflictingSessions.stream()
.map(Session::getId).collect(Collectors.toList()))
);
}
}
}
Conflict Detection Severities
The ConflictDetectionService distinguishes between hard errors and soft warnings:
| Conflict Type | Severity | Blocking |
|---|---|---|
ROOM_OVERLAP β two sessions in the same room at overlapping times |
ERROR |
Yes β slot assignment is rejected |
SPEAKER_DOUBLE_BOOKED β same speaker assigned to overlapping sessions |
ERROR |
Yes β slot assignment is rejected |
PREFERENCE_MISMATCH β session scheduled outside speakerβs preferred time window |
WARNING |
No β informational only, assignment proceeds |
Workflow State Management
The BATbern platform implements sophisticated state machines to manage the complex 9-state event workflow. These include:
- Event Workflow State Machine - Manages event lifecycle from draft to published
- Speaker Workflow Management - Tracks speaker states from invitation to final agenda
- Slot Assignment Algorithm - Optimally assigns speakers to presentation slots
- Quality Review Workflow - Manages content review and approval process
- Overflow Management & Voting - Handles speaker overflow situations with voting
See Workflow State Machines for complete implementation details.
User Lifecycle and Synchronization
The platform maintains user data across AWS Cognito (authentication) and PostgreSQL (business logic) using a database-centric approach per ADR-001:
- PostConfirmation Lambda - Creates database user on Cognito email verification
- PreTokenGeneration Lambda - Adds
custom:roleJWT claim from database - Spring Security - Extracts roles from JWT for authorization
- Unidirectional Sync - Cognito β Database only (NO Cognito Groups, NO reverse sync)
Key Point: Roles are stored as a Set<Role> directly on the User entity (not in a separate role_assignments table). Roles are synced to the JWT at login time, cached for request duration. Role updates take effect on the next login.
See User Lifecycle Sync Patterns for complete implementation details.
Role Management Service
Handles user role promotion, demotion, and approval workflows while enforcing business rules.
Deployed implementation: The production RoleService stores roles as a Set<Role> on the User entity and injects UserRepository only β there is no separate UserRoleRepository or role_assignments table. Role lookups use userRepository.findByRolesContaining(Role.ORGANIZER).
Idempotency: addRole and removeRole are idempotent. A UserRoleChangedEvent is only published when the role set actually changes (i.e. if the role was already present on add, or already absent on remove, no event is published).
Note: The snippet below is a design reference that documents the intended approval-workflow shape. The deployed
RoleServiceis simpler; refer to source for the exact implementation.
Role Management Service Implementation (design reference)
@Service
@RequiredArgsConstructor
public class RoleManagementService {
private final UserRoleRepository userRoleRepository;
private final RoleChangeRequestRepository roleChangeRequestRepository;
private final RoleChangeApprovalRepository roleChangeApprovalRepository;
private final CognitoIdentityProviderClient cognitoClient;
private final ApplicationEventPublisher eventPublisher;
@Value("${aws.cognito.user-pool-id}")
private String cognitoUserPoolId;
/**
* Promote user to a higher role
* Story 1.16.2: Uses String username instead of UUID
*/
@Transactional
public RoleChange promoteUser(String username, UserRole targetRole, String promotedByUsername, String reason) {
// Validate promotion eligibility
validatePromotion(username, targetRole);
// Create role record
UserRoleEntity roleEntity = UserRoleEntity.builder()
.username(username) // Story 1.16.2: Store username, not UUID
.role(targetRole)
.grantedBy(promotedByUsername) // Story 1.16.2: Store username, not UUID
.reason(reason)
.isActive(true)
.build();
userRoleRepository.save(roleEntity);
// NO Cognito sync - roles fetched from database on next login via PreTokenGeneration Lambda
// Per ADR-001: Database is single source of truth for roles
// Publish domain event
// Story 1.16.2: Event uses String username as aggregateId
RoleChange roleChange = mapToRoleChange(roleEntity);
eventPublisher.publishEvent(new UserRolePromotedEvent(roleChange));
return roleChange;
}
/**
* Demote user from role - immediate for Speaker, requires approval for Organizer
* Story 1.16.2: Uses String username instead of UUID
*/
@Transactional
public RoleChangeResult demoteUser(String username, UserRole currentRole, String demotedByUsername, String reason) {
if (currentRole == UserRole.ORGANIZER) {
// Check minimum organizers rule
if (!canDemoteOrganizer(username, null)) {
throw new BusinessRuleException("Cannot demote: minimum 2 organizers required");
}
// Create approval request
RoleChangeRequest request = RoleChangeRequest.builder()
.username(username) // Story 1.16.2: Store username, not UUID
.currentRole(currentRole)
.requestedRole(UserRole.ATTENDEE)
.requestedBy(demotedByUsername) // Story 1.16.2: Store username, not UUID
.requiresApprovalFrom(username)
.reason(reason)
.status(RequestStatus.PENDING)
.build();
roleChangeRequestRepository.save(request);
return RoleChangeResult.pendingApproval(request);
} else {
// Immediate demotion for non-organizers
UserRoleEntity roleEntity = userRoleRepository
.findActiveRole(username, currentRole)
.orElseThrow(() -> new NotFoundException("Active role not found"));
roleEntity.setIsActive(false);
roleEntity.setRevokedBy(demotedBy);
roleEntity.setRevokedAt(Instant.now());
userRoleRepository.save(roleEntity);
// NO Cognito sync - roles fetched from database on next login
// Per ADR-001: Database is single source of truth for roles
RoleChange roleChange = mapToRoleChange(roleEntity);
eventPublisher.publishEvent(new UserRoleDemotedEvent(roleChange));
return RoleChangeResult.completed(roleChange);
}
}
/**
* Approve organizer demotion request
* Story 1.16.2: Uses String username instead of UUID
*/
@Transactional
public RoleChange approveRoleChange(UUID requestId, String approverUsername, boolean approved, String comments) {
// Implementation details - see source code
}
/**
* Check if organizer can be demoted (minimum 2 organizers rule)
* Story 1.16.2: Uses String username instead of UUID
*/
public boolean canDemoteOrganizer(String username, String eventCode) {
long activeOrganizerCount = userRoleRepository.countActiveOrganizers(eventCode);
return activeOrganizerCount >= 2;
}
// syncRoleToCognito removed - per ADR-001, roles NOT synced to Cognito
// Roles stored in database only, added to JWT via PreTokenGeneration Lambda
private UserRole determineNewRole(String username) {
// Implementation details - see source code
}
private void validatePromotion(String username, UserRole targetRole) {
// Implementation details - see source code
}
private RoleChange mapToRoleChange(UserRoleEntity entity) {
// Implementation details - see source code
}
}
Newsletter Async Processing
The newsletter send system uses an asynchronous fire-and-forget pattern to handle large recipient lists without blocking the API or exhausting thread pools:
- Trigger:
POST /api/v1/events/{code}/newsletter/sendreturns202 Acceptedimmediately - Processing:
@Asyncmethod sends emails sequentially with SES rate limiting (~70ms/email to stay within SES quotas) - Progress tracking:
newsletter_send_statustable tracks state (PENDINGβIN_PROGRESSβCOMPLETED/PARTIAL/FAILED), withsent_countandtotal_countfor progress polling - Duplicate prevention: A second send attempt while
IN_PROGRESSreturns409 Conflict - Retry: Dedicated retry endpoint to re-send only failed recipients from a previous partial/failed send
- Orphan recovery: On service startup, any stuck
IN_PROGRESSrecords (from a crash mid-send) are reset toFAILEDfor manual retry
Email Forwarding (Serverless)
Inbound email forwarding for platform mailboxes (ok@, info@, events@, partner@, support@, batbernNN@batbern.ch) is handled entirely serverlessly:
SES Receipt Rule β S3 (raw email) β Lambda (email-forwarder)
ββ address-resolver: maps inbox β recipients via role/registration APIs
ββ sender-auth: validates sender
ββ email-rewriter: rewrites From/Reply-To headers
ββ SES sendRawEmail β forwarded to resolved recipients
- Environment isolation: Staging uses
replies@staging.batbern.ch; production usesreplies@batbern.ch(separate MX records and SES receipt rules) - Outbound sender: Staging uses
noreply@berner-architekten-treffen.ch(verified SES domain); production usesnoreply@batbern.ch - Sender exclusion: The original sender is excluded from forwarding recipients to prevent bounce loops
- Admin configuration: Organizers can manage forwarding rules via Admin Settings UI
Auto-Enrollment on Event Creation
When a new event is created, an EventCreatedEvent listener automatically enrolls all active organizers and partner contacts as stakeholders. A manual quick-action button is also available for idempotent re-enrollment (POST endpoint returns success even if already enrolled).
Real-time Notifications and Escalation
The platform provides multi-channel notifications (email, WebSocket) with intelligent escalation based on workflow state, user preferences, and deadline proximity.
Key Features:
- Real-time WebSocket notifications
- Email notifications with templating
- Automatic escalation for approaching deadlines
- User-configurable notification preferences
- Notification history and audit trail
See Notification System for complete implementation details.
Registration & Sign-up Confirmation Lifecycle
Two distinct confirmation flows exist, with different token mechanics β a frequent source of confusion:
| Flow | Token | Validity | Configurable? |
|---|---|---|---|
Event registration confirmation (status registered β confirmed) |
Our own signed JWT (ConfirmationTokenService) |
4 days (app.registration.confirmation-token-validity-hours, default 96h) |
β yes β we issue it |
| Cognito account confirmation (sign-up email verification) | Cognitoβs native sign-up code | 24h, fixed | β no β Cognito limitation |
Coupling invariant (registration): RegistrationCleanupService deletes still-registered rows
whose most-recent confirmation link is older than app.registration.cleanup-after-hours
(default 120h / 5 days). The deletion clock is keyed off COALESCE(confirmation_resent_at, created_at)
β i.e. the last time a link was minted β not created_at alone. This is required because the
resend job (below) issues a fresh full-validity token on each nudge: keying cleanup off created_at
would orphan a just-resent, still-valid link (the 2026-06-08 βConfirmation Failedβ incident). The
window must exceed the confirmation-token validity, otherwise a still-valid 4-day link could point
at an already-deleted row; the service enforces this defensively
(effectiveCleanupHours() = max(configured, tokenValidity + 24h)) and warns at startup if misconfigured.
Daily auto-resend jobs (both Spring @Scheduled + @SchedulerLock/ShedLock):
RegistrationResendService(event-management-service, 08:30) re-sends the registration-confirmation email β with fresh JWTs β to attendees unconfirmed for> after-hours(default 48h), capped atmax-attempts(default 2) viaconfirmation_resend_count/confirmation_resent_atonregistrations.CognitoConfirmationResendJob(company-user-management-service, 08:15) re-sends a fresh Cognito sign-up code to accounts unconfirmed within a bounded age window (stateless anti-spam, since UNCONFIRMED users have no DB row) β see 06b Β§Unconfirmed Sign-up Resend Job. Requirescognito-idp:ListUsers+cognito-idp:ResendConfirmationCodeon the CUMS task role.
Together these close the βfirst confirmation email missed/expired β recovery dead-end β locked outβ failure mode without manual admin intervention.
Testing Strategy
The BATbern backend uses Testcontainers PostgreSQL for all integration tests to ensure production parity. This approach catches PostgreSQL-specific issues (JSONB, functions, constraints) that would be missed with H2.
Testing Layers:
- E2E Tests - Full user journeys with Playwright
- Integration Tests - REST API + PostgreSQL via Testcontainers
- Unit Tests - Business logic with mocked dependencies
See Testing Strategy for complete implementation details and examples.
Summary
This backend architecture provides:
β Role-based security with JWT authentication via AWS Cognito β Comprehensive error handling with circuit breakers and retry logic β Sophisticated workflow state machines for the 9-state event process β Robust user synchronization across Cognito and PostgreSQL β Real-time notifications with intelligent escalation β Production-parity testing with Testcontainers PostgreSQL
The implementation follows domain-driven design principles with clear separation of concerns and robust error handling throughout the system.