Event-driven architecture - Basics

by mahidhar

Step 1: Understand the Basics

Event Basics

What are Events?
Events are significant occurrences or changes in state within a system. They can represent anything from a user action (like clicking a button) to a system change (such as a record update in a database). In software architecture, events serve as a way to communicate that something has happened, triggering subsequent actions or processes.

Role of Events in Software Architecture
Events play a crucial role in decoupling components within a system. By relying on events, components do not need to know about each other directly, which makes the system more modular, scalable, and easier to maintain.

Examples of Events

  • User Event: A user logs in to an application.
  • System Event: A new order is placed in an e-commerce system.
  • Error Event: A payment transaction fails.

Synchronous vs Asynchronous Communication

Synchronous Communication
Synchronous communication occurs when the sender waits for the receiver to process the message and respond before continuing. This type of communication is straightforward but can lead to bottlenecks if the receiver is slow or unavailable.

Example: Synchronous API Call

code
public String fetchData(String url) {
    HttpClient client = HttpClient.newHttpClient();
    HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(url))
            .build();
    HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
    return response.body();
}

Asynchronous Communication
Asynchronous communication allows the sender to continue processing without waiting for the receiver's response. This is particularly useful for improving the performance and scalability of a system, as it avoids blocking operations.

Example: Asynchronous API Call

code
public CompletableFuture<String> fetchDataAsync(String url) {
    HttpClient client = HttpClient.newHttpClient();
    HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(url))
            .build();
    return client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
            .thenApply(HttpResponse::body);
}

When to Use Each

  • Synchronous: Use when immediate feedback is required, such as in user interfaces where the user expects an instant response.
  • Asynchronous: Use when operations can be processed in the background, such as sending emails or processing large datasets, to improve system responsiveness and throughput.

Step 2: Explore Event-Driven Patterns

Pub/Sub (Publish/Subscribe)

The Publish/Subscribe pattern decouples the production of messages (events) from their consumption. Producers (publishers) send messages to a topic, and consumers (subscribers) receive messages from that topic. This pattern allows for flexible and scalable communication.

Example Scenario: User Registration

  • Publisher: User registration service publishes an event when a new user registers.
  • Subscriber: Email service subscribes to the registration event to send a welcome email.

Example Implementation with Kafka
Producer

code
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");

KafkaProducer<String, String> producer = new KafkaProducer<>(props);
producer.send(new ProducerRecord<>("user-registrations", "user123", "New User Registered"));
producer.close();

Consumer

code
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("group.id", "email-service");
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");

KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Collections.singletonList("user-registrations"));

while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
    for (ConsumerRecord<String, String> record : records) {
        System.out.printf("Sending welcome email for user: %s%n", record.value());
    }
}

Event Sourcing

Event sourcing is a pattern where the state of an application is derived from a sequence of events. Instead of storing the current state directly, all changes (events) are stored, allowing the current state to be reconstructed by replaying these events.

Example Scenario: Banking Transactions

  • Events: Deposit, Withdrawal, Transfer.
  • State: Account balance is derived by replaying all transactions.

Example Implementation
Event

code
public class BankEvent {
    private String accountId;
    private LocalDateTime timestamp;
    private String eventType;
    private double amount;

    // Constructors, getters, setters
}

Event Store

code
List<BankEvent> eventStore = new ArrayList<>();

public void saveEvent(BankEvent event) {
    eventStore.add(event);
}

Reconstructing State

code
public double getAccountBalance(String accountId) {
    return eventStore.stream()
            .filter(event -> event.getAccountId().equals(accountId))
            .mapToDouble(event -> event.getEventType().equals("Deposit") ? event.getAmount() : -event.getAmount())
            .sum();
}

CQRS (Command Query Responsibility Segregation)

CQRS is a pattern that separates the responsibility for handling commands (updates) from queries (reads). This separation allows for optimized and scalable read and write operations.

Example Scenario: E-commerce Inventory

  • Commands: Adding or removing items from the inventory.
  • Queries: Checking the available stock of an item.

Example Implementation
Command Model

code
public class InventoryCommandService {
    private final InventoryRepository repository;

    public InventoryCommandService(InventoryRepository repository) {
        this.repository = repository;
    }

    public void addItem(String itemId, int quantity) {
        repository.save(new InventoryEvent(itemId, "Add", quantity));
    }

    public void removeItem(String itemId, int quantity) {
        repository.save(new InventoryEvent(itemId, "Remove", quantity));
    }
}

Query Model

code
public class InventoryQueryService {
    private final InventoryRepository repository;

    public InventoryQueryService(InventoryRepository repository) {
        this.repository = repository;
    }

    public int getAvailableStock(String itemId) {
        return repository.getEvents(itemId).stream()
                .mapToInt(event -> event.getType().equals("Add") ? event.getQuantity() : -event.getQuantity())
                .sum();
    }
}

Repository

code
public class InventoryRepository {
    private final List<InventoryEvent> eventStore = new ArrayList<>();

    public void save(InventoryEvent event) {
        eventStore.add(event);
    }

    public List<InventoryEvent> getEvents(String itemId) {
        return eventStore.stream()
                .filter(event -> event.getItemId().equals(itemId))
                .collect(Collectors.toList());
    }
}

public class InventoryEvent {
    private final String itemId;
    private final String type;
    private final int quantity;

    public InventoryEvent(String itemId, String type, int quantity) {
        this.itemId = itemId;
        this.type = type;
        this.quantity = quantity;
    }

    // Getters
}

Conclusion

By understanding the basics of events, differentiating between synchronous and asynchronous communication, exploring key event-driven patterns like Pub/Sub, Event Sourcing, and CQRS, and implementing these concepts using practical examples, you can build robust, scalable, and maintainable event-driven systems. This foundational knowledge will enable you to leverage the full potential of event-driven architecture in your software projects.