Cross-Service Integration Guide
This guide walks a developer from a clean machine to a working three-service Virtufin deployment: API Gateway, WebSocketManager, and WorkManager. It covers deploy order, the Dapr components they share, topic ownership, and a minimal single-node deployment.
Scope: The three services covered here are the same trio exposed at
api.docs.virtufin.com,websocketmanager.docs.virtufin.com, andworkmanager.docs.virtufin.com. Each service has its own detailed documentation; this guide focuses on the wiring between them.
Related specs
- cross-cutting spec — the architectural rule (api is the only service that talks to Dapr directly; per-service pubsub topics; per-service state)
- worker-management spec — worker lifecycle event contract
- websocket-proxy spec — WebSocket connection lifecycle event contract
Deploy order
The three services have a strict startup dependency on each other:
1. virtufin-api (must be first — every other service talks to it)
|
v
2. virtufin-websocketmanager (subscribes to api state + pubsub via api gRPC)
|
v
3. virtufin-workmanager (subscribes to api state + pubsub, plus deploys workers)
Why this order?
- virtufin-api is the only service that talks to Dapr pubsub
and the state store directly. Per the
cross-cutting spec §Per-service Pubsub Topics,
all state and pubsub operations go through the API's
State.*andPubsub.*gRPC RPCs. The other two services use aVirtufin.Api.ClientNuGet wrapper that calls those RPCs. - virtufin-websocketmanager is independent of the workmanager — it only needs the API to be up so it can publish connection lifecycle events and read/write its per-service state entry.
- virtufin-workmanager is independent of the websocketmanager too — it talks only to the API for state, pubsub, and gateway calls (workers route their backend service calls through the API Gateway).
Topic ownership
Per the cross-cutting spec, each service owns one lifecycle topic:
| Service | Topic (constant) | Source Configuration/Topics.cs |
|---|---|---|
| WebSocketManager | websocketmanager.connectionstatus |
virtufin-websocketmanager/src/Virtufin.WebSocketManager/Configuration/Topics.cs:ConnectionStatus |
| WorkManager | workmanager.workerstatus |
virtufin-workmanager/src/Virtufin.WorkManager/Configuration/Topics.cs:WorkerStatus |
The API itself does not own a lifecycle topic — it is the
messaging substrate, not a participant. Workers do not own
topics either; they receive commands on the topic assigned at
deployment time by the WorkManager (e.g. command.websocketmanagercontroller).
Wiring diagram
┌──────────────────────────┐
│ virtufin-api │
│ (only Dapr-aware svc) │
└────┬──────────┬──────────┘
│ │
State.* gRPC │ │ Pubsub.* gRPC
│ │
┌────────────────┼──────────┼─────────────────┐
│ │ │ │
v v v v
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ virtufin-websocket- │ │ virtufin-work- │ │ external clients │
│ manager │ │ manager │ │ (browser, SDKs) │
│ - owns: websocket- │ │ - owns: workmgr. │ │ - talk via api │
│ manager.connec- │ │ workerstatus │ │ REST/gRPC │
│ tionstatus │ │ - deploys workers │ └────────────────────┘
└────────────────────┘ └─────────┬──────────┘
│
│ deploys
v
┌────────────────────────┐
│ worker processes │
│ (DotNetDLL, Native, │
│ Python, CSharpSrc) │
└────────────────────────┘
Reading the diagram: Solid arrows are gRPC + Dapr-backed calls. The API is the only service that holds Dapr components; the other two services route everything through the API gRPC interface. Workers are spawned by the WorkManager and have no direct Dapr access.
Health-check sequence at startup
Each service has a IHostedService (or equivalent) that gates
its readiness probe. Recommended Kubernetes probe config:
readinessProbe:
httpGet:
path: /health
port: 5001
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health/live
port: 5001
initialDelaySeconds: 30
periodSeconds: 30
Service readiness gates:
| Service | Readiness gates |
|---|---|
virtufin-api |
Dapr sidecar healthy + state store reachable + pubsub broker reachable |
virtufin-websocketmanager |
Dapr sidecar healthy + API gRPC handshake (Health.HealthCheck) + state read/write roundtrip |
virtufin-workmanager |
Dapr sidecar healthy + API gRPC handshake + (optionally) auto-recovery of orphan workers (see WM #15) |
The dependency order matters: do not mark a downstream service ready until the API gRPC handshake succeeds. If the API is restarted, downstream services will need to re-handshake; they should fail their liveness probe and the orchestrator will restart them.
Shared Dapr components
All three services share a single Dapr installation and a single set of components. Per the cross-cutting spec §Per-service State Service Names, each service uses its own state service entry but the underlying state store is shared.
Shared components
| Component | Type | Owner | Notes |
|---|---|---|---|
statestore |
state.redis (Valkey backend) |
API only — the other two services call State.* gRPCs against the API |
single backing instance |
pubsub |
pubsub.redis (Valkey streams) |
API only — same model | single backing instance |
kubernetes-secrets |
secretstores.kubernetes |
API only | Dapr resolves keys; services read via DaprClient.GetSecretAsync |
daprd (sidecar) |
per-pod | every service | each service has its own sidecar with app-protocol: grpc |
Per-service components
None. The services do not declare their own Dapr components — they share the API's components via gRPC indirection.
Minimal single-node deployment
A complete docker-compose.yml for a local three-service stack:
services:
valkey:
image: valkey/valkey:8
ports: ["6379:6379"]
api:
image: virtufin-api:latest
environment:
ASPNETCORE_URLS: http://+:5001
VIRTUFIN_GRPC_PORT: 5002
DAPR_GRPC_PORT: 50001
DAPR_HTTP_PORT: 3500
ports: ["5001:5001", "5002:5002"]
depends_on: [valkey]
websocketmanager:
image: virtufin-websocketmanager:latest
environment:
ASPNETCORE_URLS: http://+:5001
VIRTUFIN_GRPC_PORT: 5002
VIRTUFIN_API_HOST: api
VIRTUFIN_API_GRPC_PORT: 5002
DAPR_GRPC_PORT: 50001
DAPR_HTTP_PORT: 3500
ports: ["5001:5001", "5002:5002"]
depends_on: [api]
workmanager:
image: virtufin-workmanager:latest
environment:
ASPNETCORE_URLS: http://+:5001
VIRTUFIN_GRPC_PORT: 5002
VIRTUFIN_API_HOST: api
VIRTUFIN_API_GRPC_PORT: 5002
DAPR_GRPC_PORT: 50001
DAPR_HTTP_PORT: 3500
ports: ["5001:5001", "5002:5002"]
depends_on: [api]
Each service runs with its own Dapr sidecar — in production
use a Dapr control plane install (dapr init -k) rather than
the per-pod sidecar injection via daprd.
Minimal Kubernetes deployment
For a single-node Kubernetes deployment (kind, minikube, or
a small cluster):
apiVersion: apps/v1
kind: Deployment
metadata: { name: virtufin-api }
spec:
replicas: 1
selector: { matchLabels: { app: virtufin-api } }
template:
metadata:
labels: { app: virtufin-api }
annotations:
dapr.io/enabled: "true"
dapr.io/app-id: "virtufin-api"
dapr.io/app-port: "5001"
dapr.io/app-protocol: "grpc"
dapr.io/enable-app-health-check: "false"
spec:
containers:
- name: api
image: virtufin-api:0.0.47
ports: [{ containerPort: 5001 }, { containerPort: 5002 }]
env:
- { name: ASPNETCORE_URLS, value: "http://+:5001" }
- { name: VIRTUFIN_GRPC_PORT, value: "5002" }
---
apiVersion: v1
kind: Service
metadata: { name: virtufin-api }
spec:
selector: { app: virtufin-api }
ports:
- { name: http, port: 5001, targetPort: 5001 }
- { name: grpc, port: 5002, targetPort: 5002 }
The WebSocketManager and WorkManager deployments are identical
except for app-id and image, with one extra env var:
env:
- { name: VIRTUFIN_API_HOST, value: "virtufin-api" }
- { name: VIRTUFIN_API_GRPC_PORT, value: "5002" }
The dependency chain is enforced by Kubernetes' normal pod scheduling — the API pod just needs to be ready before the downstream services start their gRPC handshake.
Service-to-service gRPC contracts
| Caller | Callee | RPC surface | Purpose |
|---|---|---|---|
| WebSocketManager | virtufin-api | State.*, Pubsub.* |
connection lifecycle state + events |
| WorkManager | virtufin-api | State.*, Pubsub.*, Gateway.* |
worker registry + gateway calls for worker backends |
| Worker (via DevKit) | virtufin-api | Gateway.* |
service-to-service calls from worker code |
| Worker (via DevKit) | WorkManager | (none — WM owns the worker) | workers are spawned by WM |
All gRPC contracts are exposed via the Virtufin.Api.Client,
Virtufin.WebSocketManager.Client, and Virtufin.WorkManager.Client
NuGet packages — never call them via raw gRPC stubs unless you're
writing a new client in a language that doesn't have a SDK yet.
Verifying the deployment
After all three services report Healthy, run this sanity check
from any pod with grpcurl available:
# API health
grpcurl -plaintext virtufin-api:5002 virtufin.api.v1.Health/HealthCheck
# Subscribe WSM to its own topic via API
grpcurl -plaintext virtufin-api:5002 virtufin.api.v1.Pubsub/Subscribe \
-d '{"topic":"websocketmanager.connectionstatus","subscriber":"test","group":"smoke"}'
# Publish a test event via API (will fan out to WSM)
grpcurl -plaintext virtufin-api:5002 virtufin.api.v1.Pubsub/PublishEvent \
-d '{"topic":"websocketmanager.connectionstatus","data":"{\"connection_id\":\"smoke\",\"url\":\"ws://localhost\"}","metadata":{"ce-type":"com.virtufin.websocketmanager.connection.connected","ce-source":"urn:test","ce-id":"smoke-1"}}'
If both calls return OK, the wiring is functional. Tear down the smoke subscription afterwards:
grpcurl -plaintext virtufin-api:5002 virtufin.api.v1.Pubsub/Unsubscribe \
-d '{"topic":"websocketmanager.connectionstatus","subscriber":"test","group":"smoke"}'
See also
- Getting Started — single-service WM quick start
- Architecture — WM-specific architecture details
- DevKit — generic WorkerBase contract for new workers
- Security — Dapr mTLS, content-source allow-list, audit logging