Event-driven architectures are powerful. Services communicate through events, staying loosely coupled while enabling complex workflows. This loose coupling comes with a tradeoff: as your system grows, understanding how services connect becomes increasingly difficult.
"Which services consume the OrderCreated event?"
"What happens if we change the PaymentCompleted event schema?"
"How do events flow through our system?"
In this article, I'll show you how to build a service graph visualization for EventBridge-based architectures using two complementary discovery mechanisms:
- Producer Discovery: Using EventBridge Schema Registry to automatically discover which services produce which events
- Consumer Discovery: Using a tagging convention on EventBridge Rules to identify which services consume which events
By combining these two data sources, we can generate an accurate, up-to-date visualization of how services communicate through events.
Setting Up Schema Registry for Producer Discovery
The first piece of our puzzle is discovering which services produce which events. EventBridge Schema Registry with automatic discovery handles this for us.
When you enable schema discovery on an event bus, EventBridge automatically creates schemas for every unique event type it sees. The schema names follow the pattern {source}@{DetailType}, for example: order-service@OrderCreated. This gives us what we need: a mapping from event sources to the events they produce.
AWS-CDK Setup
Here's TypeScript AWS-CDK code:
import * as cdk from 'aws-cdk-lib';
import * as events from 'aws-cdk-lib/aws-events';
import * as eventschemas from 'aws-cdk-lib/aws-eventschemas';
import type { Construct } from 'constructs';
export interface SchemaDiscovererStackProps extends cdk.StackProps {
eventBus: events.IEventBus;
}
export class SchemaDiscovererStack extends cdk.Stack {
public readonly discoverer: eventschemas.CfnDiscoverer;
constructor(scope: Construct, id: string, props: SchemaDiscovererStackProps) {
super(scope, id, props);
this.discoverer = new eventschemas.CfnDiscoverer(this, 'SchemaDiscoverer', {
sourceArn: props.eventBus.eventBusArn,
description: `Schema Discoverer for ${props.eventBus.eventBusName} event bus`,
});
}
Or, in pure CloudFormation:
CloudFormation Setup
AWSTemplateFormatVersion: '2010-09-09'
Description: EventBridge Schema Registry with automatic discovery
Parameters:
EventBusName:
Type: String
Default: default
Description: Name of the EventBridge event bus to discover schemas from
Resources:
SchemaDiscoverer:
Type: AWS::EventSchemas::Discoverer
Properties:
SourceArn: !Sub 'arn:aws:events:${AWS::Region}:${AWS::AccountId}:event-bus/${EventBusName}'
Description: !Sub 'Schema Discoverer for ${EventBusName} event bus'
Outputs:
DiscovererId:
Description: ID of the Schema Discoverer
Value: !Ref SchemaDiscoverer
Schema Limit Considerations
By default, Schema Registry has a limit of 200 discovered schemas per registry. If your bus has more than 200 unique event types, you'll need to request a limit increase through AWS Support.
Establishing a Rule Tagging Convention for Consumer Discovery
Schema Registry tells us who produces events, but we also need to know who consumes them. EventBridge Rules define event consumers, and there's a simple way to add metadata for which services are consuming which events: tag every EventBridge Rule with a Service tag containing the consuming service's name.
The Tagging Convention
-
Tag Key:
Service - Tag Value: Service name
This convention is straightforward to implement and gives us everything we need to discover consumers.
CDK Implementation
Here's how to create a service stack that automatically tags all rules with the service name:
import * as cdk from 'aws-cdk-lib';
import * as events from 'aws-cdk-lib/aws-events';
import type { Construct } from 'constructs';
export interface ServiceStackProps extends cdk.StackProps {
serviceName: string;
consumedEvents: string[];
eventBus: events.IEventBus;
}
export class ServiceStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: ServiceStackProps) {
super(scope, id, props);
const { serviceName, consumedEvents, eventBus } = props;
// Apply Service tag at stack level - propagates to all rules
cdk.Tags.of(this).add('Service', serviceName);
// Create a Rule for each consumed event
for (const consumedEvent of consumedEvents) {
const rule = new events.Rule(this, `Rule-${consumedEvent}`, {
ruleName: `${serviceDefinition.name}-${consumedEvent}`,
eventBus,
eventPattern: {
detailType: [consumedEvent],
},
});
}
}
}
By applying the tag at the stack level with cdk.Tags.of(this).add('Service', serviceName), every rule created in the stack automatically inherits the tag.
CloudFormation Example
For CloudFormation users, here's how to create a tagged rule:
Resources:
TaggedRule:
Type: AWS::Events::Rule
Properties:
Name: !Sub '${ServiceName}-${DetailType}'
EventBusName: !Ref EventBusName
EventPattern:
detail-type:
- !Ref DetailType
State: ENABLED
Tags:
- Key: Service
Value: !Ref ServiceName
The key is being consistent. Every team creating EventBridge Rules needs to follow this convention. Consider adding validation in your CI/CD pipeline to ensure all rules have the Service tag. We handle this by creating a custom internal projen project type.
Discovering Producers and Consumers
With Schema Registry capturing producers and tagged Rules identifying consumers, we can now query both to build our service graph.
Producer Discovery
The ProducerDiscovery code queries Schema Registry and parses schema names to extract source/detail-type pairs:
import {
SchemasClient,
ListSchemasCommand,
} from '@aws-sdk/client-schemas';
export interface ProducerRecord {
source: string;
detailType: string;
schemaName: string;
}
export class ProducerDiscovery {
private readonly client: SchemasClient;
private readonly registryName: string;
constructor(registryName: string, client?: SchemasClient) {
this.registryName = registryName;
this.client = client ?? new SchemasClient({});
}
async discover(): Promise<ProducerRecord[]> {
const producers: ProducerRecord[] = [];
let nextToken: string | undefined;
do {
const command = new ListSchemasCommand({
RegistryName: this.registryName,
NextToken: nextToken,
});
const response = await this.client.send(command);
if (response.Schemas) {
for (const schema of response.Schemas) {
if (schema.SchemaName) {
const parsed = this.parseSchemaName(schema.SchemaName);
if (parsed) {
producers.push({
source: parsed.source,
detailType: parsed.detailType,
schemaName: schema.SchemaName,
});
}
}
}
}
nextToken = response.NextToken;
} while (nextToken);
return producers;
}
private parseSchemaName(schemaName: string): { source: string; detailType: string } | null {
const separatorIndex = schemaName.indexOf('@');
if (separatorIndex === -1) return null;
const source = schemaName.substring(0, separatorIndex);
const detailType = schemaName.substring(separatorIndex + 1);
if (!source || !detailType) return null;
return { source, detailType };
}
}
Consumer Discovery
The ConsumerDiscovery code lists all Rules, retrieves their Service tags, and extracts detail-type patterns:
import {
EventBridgeClient,
ListRulesCommand,
ListTagsForResourceCommand,
} from '@aws-sdk/client-eventbridge';
export interface ConsumerRecord {
serviceName: string;
detailType: string;
ruleName: string;
}
export class ConsumerDiscovery {
private readonly client: EventBridgeClient;
private readonly eventBusName: string;
constructor(eventBusName: string, client?: EventBridgeClient) {
this.eventBusName = eventBusName;
this.client = client ?? new EventBridgeClient({});
}
async discover(): Promise<ConsumerRecord[]> {
const consumers: ConsumerRecord[] = [];
const rules = await this.listAllRules();
for (const rule of rules) {
if (!rule.Name || !rule.Arn) continue;
const serviceName = await this.getServiceTag(rule.Arn);
if (!serviceName) {
console.warn(`Skipping rule "${rule.Name}": missing Service tag`);
continue;
}
const detailTypes = this.extractDetailTypes(rule.EventPattern);
for (const detailType of detailTypes) {
consumers.push({
serviceName,
detailType,
ruleName: rule.Name,
});
}
}
return consumers;
}
private async listAllRules() {
const rules: any[] = [];
let nextToken: string | undefined;
do {
const command = new ListRulesCommand({
EventBusName: this.eventBusName,
NextToken: nextToken,
});
const response = await this.client.send(command);
if (response.Rules) rules.push(...response.Rules);
nextToken = response.NextToken;
} while (nextToken);
return rules;
}
private async getServiceTag(ruleArn: string): Promise<string | undefined> {
const command = new ListTagsForResourceCommand({ ResourceARN: ruleArn });
const response = await this.client.send(command);
return response.Tags?.find(tag => tag.Key === 'Service')?.Value;
}
private extractDetailTypes(eventPattern?: string): string[] {
if (!eventPattern) return [];
try {
const pattern = JSON.parse(eventPattern);
const detailType = pattern['detail-type'];
if (Array.isArray(detailType)) {
return detailType.filter(item => typeof item === 'string');
}
if (typeof detailType === 'string') {
return [detailType];
}
return [];
} catch {
return [];
}
}
}
Rules without a Service tag are skipped with a warning. This helps identify rules that haven't adopted the tagging convention yet.
Generating the Service Graph
Now we combine producer and consumer data to generate a visual graph. The ServiceGraphGenerator code creates nodes for each service and directed edges based on shared detail-types.
export interface ServiceNode {
id: string;
name: string;
producedEvents: string[];
consumedEvents: string[];
}
export interface ServiceEdge {
from: string;
to: string;
detailTypes: string[];
}
export interface ServiceGraph {
nodes: ServiceNode[];
edges: ServiceEdge[];
}
export class ServiceGraphGenerator {
private readonly producers: ProducerRecord[];
private readonly consumers: ConsumerRecord[];
constructor(producers: ProducerRecord[], consumers: ConsumerRecord[]) {
this.producers = producers;
this.consumers = consumers;
}
generateGraph(): ServiceGraph {
const services = new Map<string, { produces: Set<string>; consumes: Set<string> }>();
const eventTypes = new Map<string, { producers: Set<string>; consumers: Set<string> }>();
// Process producers
for (const producer of this.producers) {
if (!services.has(producer.source)) {
services.set(producer.source, { produces: new Set(), consumes: new Set() });
}
services.get(producer.source)!.produces.add(producer.detailType);
if (!eventTypes.has(producer.detailType)) {
eventTypes.set(producer.detailType, { producers: new Set(), consumers: new Set() });
}
eventTypes.get(producer.detailType)!.producers.add(producer.source);
}
// Process consumers
for (const consumer of this.consumers) {
if (!services.has(consumer.serviceName)) {
services.set(consumer.serviceName, { produces: new Set(), consumes: new Set() });
}
services.get(consumer.serviceName)!.consumes.add(consumer.detailType);
if (!eventTypes.has(consumer.detailType)) {
eventTypes.set(consumer.detailType, { producers: new Set(), consumers: new Set() });
}
eventTypes.get(consumer.detailType)!.consumers.add(consumer.serviceName);
}
// Generate nodes
const nodes: ServiceNode[] = Array.from(services.entries()).map(([name, data]) => ({
id: name,
name,
producedEvents: Array.from(data.produces).sort(),
consumedEvents: Array.from(data.consumes).sort(),
}));
// Generate edges (aggregate multiple detail-types per service pair)
const edgeMap = new Map<string, Set<string>>();
for (const [detailType, data] of eventTypes) {
for (const producer of data.producers) {
for (const consumer of data.consumers) {
if (producer === consumer) continue; // Skip self-loops
const edgeKey = `${producer}|${consumer}`;
if (!edgeMap.has(edgeKey)) edgeMap.set(edgeKey, new Set());
edgeMap.get(edgeKey)!.add(detailType);
}
}
}
const edges: ServiceEdge[] = Array.from(edgeMap.entries()).map(([key, types]) => {
const [from, to] = key.split('|');
return { from: from!, to: to!, detailTypes: Array.from(types).sort() };
});
return { nodes: nodes.sort((a, b) => a.name.localeCompare(b.name)), edges };
}
toMermaid(): string {
const graph = this.generateGraph();
const lines: string[] = ['graph LR'];
for (const node of graph.nodes) {
const nodeId = node.id.replace(/-/g, '_');
lines.push(` ${nodeId}["${node.name}"]`);
}
for (const edge of graph.edges) {
const fromId = edge.from.replace(/-/g, '_');
const toId = edge.to.replace(/-/g, '_');
const label = edge.detailTypes.join(', ');
lines.push(` ${fromId} -->|"${label}"| ${toId}`);
}
return lines.join('\n');
}
toDot(): string {
const graph = this.generateGraph();
const lines: string[] = [
'digraph ServiceGraph {',
' rankdir=LR;',
' node [shape=box, style=rounded];',
'',
];
for (const node of graph.nodes) {
const nodeId = node.id.replace(/-/g, '_');
lines.push(` ${nodeId} [label="${node.name}"];`);
}
lines.push('');
for (const edge of graph.edges) {
const fromId = edge.from.replace(/-/g, '_');
const toId = edge.to.replace(/-/g, '_');
const label = edge.detailTypes.join('\\n');
lines.push(` ${fromId} -> ${toId} [label="${label}"];`);
}
lines.push('}');
return lines.join('\n');
}
}
Example Output
For an e-commerce system with five services (order, payment, inventory, shipping, notification), the generated Mermaid diagram looks like this:
graph LR
inventory_service["inventory-service"]
notification_service["notification-service"]
order_service["order-service"]
payment_service["payment-service"]
shipping_service["shipping-service"]
inventory_service -->|"InventoryReserved"| order_service
inventory_service -->|"InventoryReserved"| shipping_service
order_service -->|"OrderCancelled, OrderCreated"| inventory_service
order_service -->|"OrderCancelled, OrderCreated"| payment_service
order_service -->|"OrderCreated"| notification_service
payment_service -->|"PaymentCompleted"| notification_service
payment_service -->|"PaymentCompleted"| order_service
payment_service -->|"PaymentCompleted"| shipping_service
payment_service -->|"PaymentFailed"| inventory_service
shipping_service -->|"ShipmentDispatched"| notification_service
The graph clearly shows:
- Order service produces events consumed by payment, inventory, and notification services
- Payment service produces events consumed by order, shipping, inventory, and notification services
- The notification service is a pure consumer—it doesn't produce events that other services consume
Putting It All Together
Here's how to use these components together:
async function generateServiceGraph() {
const registryName = 'discovered-schemas';
const eventBusName = 'default';
// Discover producers from Schema Registry
const producerDiscovery = new ProducerDiscovery(registryName);
const producers = await producerDiscovery.discover();
// Discover consumers from EventBridge Rules
const consumerDiscovery = new ConsumerDiscovery(eventBusName);
const consumers = await consumerDiscovery.discover();
// Generate the graph
const generator = new ServiceGraphGenerator(producers, consumers);
// Output as Mermaid
console.log(generator.toMermaid());
// Or output as Graphviz DOT
console.log(generator.toDot());
}
Conclusion
It is a relatively low lift effort to be able to understand service relationships in event-driven architectures using EventBridge. By combining EventBridge Schema Registry for producer discovery with a simple tagging convention for consumer discovery, you can automatically generate accurate visualizations of how events flow through your system.
The code in this article is available as a complete, deployable CDK application.

Top comments (0)