PROJECT / Angular
Microservices exploration
A Java and Angular architecture exploration of OAuth2 routing, separate user and chat services, and real-time ticket messaging.
- Angular
- Java
- Spring Boot
- Microservices
- Keycloak
- WebSocket
Context and constraints
This personal architecture exploration builds on the Lead Developer Java Angular program. It is not client work. I used it to explore service boundaries, identity and real-time messaging in a working application.
Goal
The application separates user profiles from ticket-based chat. Keycloak owns authentication; the user and chat services handle their own domain data and can evolve behind a shared gateway.
Build
Architecture
Spring Cloud Gateway is the entry point for client requests. It routes user and chat API calls to separate Spring Boot services and relays OAuth2 access tokens. Keycloak manages sign-in and roles; each service validates tokens and owns a separate PostgreSQL database. Docker Compose brings up the gateway, services, databases and Keycloak together for local development.
The gateway declares these routes in application.yml. Only the HTTP APIs relay the access token; the WebSocket route is forwarded as is.
spring.cloud.gateway.server.webflux.routes:
- id: chat-websocket-route
uri: ${CHAT_INTERNAL_URL:http://localhost:8082}
predicates:
- Path=/ws, /ws/**
- id: chat-service-route
uri: ${CHAT_INTERNAL_URL:http://localhost:8082}
predicates:
- Path=/api/chat/**
filters:
- TokenRelay=
- id: user-service-route
uri: ${USER_INTERNAL_URL:http://localhost:8081}
predicates:
- Path=/api/user/**
filters:
- TokenRelay=
Real-time ticket messaging
The Angular client loads ticket history over HTTP, then uses STOMP over WebSocket for live messages. It publishes to a ticket-specific endpoint; the chat service persists each message and broadcasts it to subscribers of that ticket. This keeps message delivery in the chat service while the gateway handles the client-facing route.
@MessageMapping("/tickets/{ticketId}/messages")
public void sendMessage(@DestinationVariable UUID ticketId,
@Payload TicketMessageSendRequest request,
SimpMessageHeaderAccessor headerAccessor) {
Map<String, Object> attrs = headerAccessor.getSessionAttributes();
UUID senderId = UUID.fromString((String) attrs.get("userId"));
String senderUsername = (String) attrs.get("username");
TicketMessageResponse response = ticketMessageService
.createMessage(ticketId, senderId, senderUsername, request);
messagingTemplate.convertAndSend("/topic/tickets/" + ticketId, response);
}
The userId and username session attributes are not sent by the browser. The WebSocket route does not relay the access token, so the gateway authenticates the session instead and adds the identity as headers. The chat service stores them during the handshake and rejects connections without a user id.
// Gateway: global filter, runs before routing
@Component
public class WebSocketUserHeaderFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
if (!exchange.getRequest().getPath().value().startsWith("/ws")) {
return chain.filter(exchange);
}
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.flatMap(auth -> {
OidcUser user = (OidcUser) auth.getPrincipal();
ServerWebExchange mutated = exchange.mutate()
.request(exchange.getRequest().mutate()
.header("X-User-Id", user.getSubject())
.header("X-Username", user.getPreferredUsername())
.build())
.build();
return chain.filter(mutated);
})
.switchIfEmpty(Mono.defer(() -> {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}));
}
}
The chat service permits /ws without a JWT, so this design relies on the gateway being the only way to reach it.
Outcome
The repository demonstrates the request path from the Angular client through OAuth2 token relay to the owning service, plus the ticket message flow over WebSocket. I have not measured performance under load.
The source code for this exploration is available on GitHub.