← All projects

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.

Microservices component diagram An Angular client uses a Spring Cloud Gateway for user and chat APIs. Keycloak handles OIDC, and the user, chat, and identity components each use a separate PostgreSQL database. HTTP API + /ws OIDC login /api/user/** OAuth2 TokenRelay /api/chat/** OAuth2 TokenRelay /ws/** WebSocket <<client>> Angular client Profiles, tickets, chat <<API gateway>> Spring Cloud Gateway OAuth2 login and route dispatch Token relay for HTTP APIs <<identity provider>> Keycloak OIDC sign-in and roles <<database>> PostgreSQL • identity <<service>> User service Spring Boot • validates JWTs Profile API <<service>> Chat service Spring Boot • validates JWTs Ticket API and STOMP chat <<database>> PostgreSQL • user_db <<database>> PostgreSQL • chat_db
HTTP routes use OAuth2 token relay; the separate dashed route forwards STOMP/WebSocket traffic to the chat service. Each service owns its database.

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.