DEV Community

Cover image for Simple Service Relationships in EventBridge
Roger Chi for AWS Community Builders

Posted on

Simple Service Relationships in EventBridge

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:

  1. Producer Discovery: Using EventBridge Schema Registry to automatically discover which services produce which events
  2. 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.

Service communication

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`,
    });
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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],
        },
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

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 [];
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

Example Output

For an e-commerce system with five services (order, payment, inventory, shipping, notification), the generated Mermaid diagram looks like this:

Service communication

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
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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)