Modern architectural approaches: from monolith to event-driven systems


Introduction

Architecture is more than just a way to arrange classes and modules. It is the language a system uses to communicate time. Today, Java developers live in a world where the boundaries between services, data flows, and events are becoming increasingly thin.

"A good architecture doesn't impose form—it creates room for evolution."

—Greg Young, author of the Event Sourcing concept

1. The Evolution of Architectures

Stage Description Pros Cons
Monolith The entire application is a single unit: business logic, UI, database. Easy to deploy, easy to test locally. Difficult to scale, difficult to maintain as the code grows.
Microservices Each service is autonomous and communicates via REST/gRPC. Scalability, team independence. Complexity in DevOps, transactions are distributed, observability is more difficult.
Event-driven systems Services interact via event streams (Kafka, RabbitMQ). Asynchronous, flexible, natural auditing of actions. Increased complexity, more difficult to debug and monitor the data flow.

2. CQRS and Event Sourcing

In the classic approach, one model is responsible for both reading and writing data. However, under high loads and complex business logic, this leads to redundancy and confusion. CQRS (Command Query Responsibility Segregation) separates them.


// Example: Simple Order Update Command (Command)
public record UpdateOrderStatusCommand(UUID orderId, String newStatus) {}

// Command Handler
public class UpdateOrderHandler {
private final OrderRepository repository;

public UpdateOrderHandler(OrderRepository repository) {
this.repository = repository;
}

public void handle(UpdateOrderStatusCommand cmd) {
var order = repository.findById(cmd.orderId());
order.setStatus(cmd.newStatus());
repository.save(order);
}
}

In Event Sourcing, data is not overwritten, but stored as a stream of events: OrderCreated → OrderPaid → OrderShipped → OrderDelivered. Thus, the system state can be reconstructed from history.

"History is the best source of truth. In Event Sourcing, history is not erased, but used."

3. DDD — Designing with Meaning

Domain-Driven Design (DDD) teaches you to build code around concepts familiar to the business. It is a language understood by both programmers and analysts.


// Example: DDD entity
@Entity
public class Order {

    @Id
    private UUID id;
    private OrderStatus status;
    private Money total;

    public void markAsPaid() {
        if (status != OrderStatus.CREATED)
            throw new IllegalStateException("Cannot pay for an order in this status");
        status = OrderStatus.PAID;
    }
}

DDD helps create a resilient architecture where the code reflects the business, not just CRUD operations.

4. Reactive Architectures

When data arrives in streams—millions of events per second—classic REST services begin to overwhelm. Reactive frameworks (Project Reactor, RxJava, Vert.x) allow you to build uncontrollably scalable systems.


Flux.fromIterable(orderIds)
    .flatMap(this::loadOrder)
    .filter(order -> order.isPending())
    .flatMap(this::processOrder)
    .subscribe();

Here, every action is asynchronous, and data flows are managed declaratively—without manual threads or locks.

5. How to Choose an Approach

Situation Recommended approach
Startup, MVP, small team Monolith or modular monolith
Growing system with multiple domains DDD + microservices
Large event stream, integration with external services CQRS + Event Sourcing + reactivity

Pitfalls

No pattern is born with the label "perfect for all cases." Here's a detailed analysis of the real pitfalls of three key approaches and practical ways to avoid them.

Event Sourcing - Explodes Complexity During Migration

Problem: In Event Sourcing, events become the actual state store. Any change to the model (e.g., adding a field, changing the meaning of an event) is a change to the "history." This makes migrations and event schema evolution much more complex than a simple table migration.

  • Example of pain: The OrderCreated structure needs to be changed—all old events are incompatible with the new deserializer; When trying to rebuild the aggregate, it crashes.
  • Why this is dangerous: A replay of old events is necessary for restoring state, building new read models, and debugging; an error in an event handler can corrupt the entire system.

Practical Mitigations

  1. Event Versioning. Each event contains a schema version. When reading, use an upcaster/adapter that knows how to transform the old version to the new one.
  2. Upcasters / Event Migrators. Implement a layer that transforms events on the fly before deserialization or performs offline migration in a separate, safe step.
  3. Snapshots. Store periodic snapshots of the aggregate state—this reduces the number of replays and provides a checkpoint for changes.
  4. Testable Evolution. Write integration tests that replay real past events (test fixtures) and verify the correctness of upcasting.
  5. Rollback Strategy. Plan a way to return the system to its previous state (e.g., feature flags, the ability to read the old model in parallel).

A Short Checklist

Version events • Have an upcaster • Store snapshots • Test replays • Provide rollback

DDD — Requires Discipline in Naming Entities (and More)

Problem: DDD isn't just a set of classes; it's a communication methodology. Inconsistent terminology, mixed bounded contexts, and poor aggregate design quickly turn the "language of business" into a mess.

  • An example of a pain point: in one service called "Client," in another called "Customer," in a third called "Account"—the team doesn't understand where the boundaries are and who is responsible for what.
  • Why this is dangerous: Mismatched ubiquitous languages ​​lead to integration bugs, endless mappings, and the inability to clearly formulate invariants.

Practical mitigations

  1. Ubiquitous Language in code. Class, method, and event names should match the terms used by the business. Document contracts directly in the code (javadoc, comments, README in the repository).
  2. Bounded Contexts and Context Map. Explicitly describe boundaries: which terms are local, which are translated via the Anti-Corruption Layer (ACL).
  3. Aggregate design: small, strong invariants. Design aggregates so that they contain only the data and rules that guarantee the atomicity of aggregate transactions. Don't create "God aggregates."
  4. Anti-corruption layer. When integrating with external/other contexts, use adapters that translate foreign terms into your language and back.
  5. Consult with domain experts. Regular workshops with business analysts keep the language precise and vibrant.

Example of a code error


// Bad: mixing roles, unclear names
public class AccountService {
    // What is an account here? Client? Balance? Contract?
}

// Better: explicitly reflect the domain term
public class CustomerAccountAggregate {

    private CustomerId id;
    private AccountBalance balance;

    public void applyPayment(Money amount) { ... }
}

Microservices Increase Infrastructure Costs

Problem: The microservice topology shifts complexity from code to infrastructure. The number of deployments, monitoring, network requests, and potential points of failure grows linearly (and sometimes exponentially).

  • Example of pain: 20 services = 20 applications to deploy, 20 monitoring schemes, 20 configuration sets. The team spends half their time on network and CI/CD support.
  • Why this is dangerous: The cost (time + money) of maintaining microservices can outweigh the benefits of their modularity, especially for small teams or low traffic.

Practical Mitigations

  1. Start with a modular monolith. Until there's a real need for separation, keep the code in a single repository with clearly defined modules.
  2. DevOps Automation. Invest in CI/CD, deployment templates, and infrastructure as code (Terraform, Helm). The initial investment is offset by the reduction in manual efforts.
  3. Observability by default. Structured message logging, metrics, distributed tracing (OpenTelemetry), alerts, and runbooks are mandatory.
  4. Contracts and integration tests. Consumer-Driven Contracts (Pact), e2e and integration tests for critical paths. Mocking is no substitute for contract tests.
  5. Network resilience patterns. Circuit breakers, retry with backoff, bulkheads, and timeouts should be built into client libraries by default.
  6. Cost optimization: vertical and horizontal autoscaling, multi-tenancy for low-load services, and using serverless where it saves money.

Operational checklist

CI/CD automation • observability • SLA/SLO • contracts • resilience patterns • cost control


Summary – what to remember

Any architectural idea brings with it both benefits and hidden costs. An architect isn't someone who chooses patterns, but someone who predicts their future costs and designs risk mitigation strategies in advance.

Conclusion

Architecture isn't a religion, but a set of tools. Monolith, microservices, CQRS, Event Sourcing, or reactive streams—each approach solves its own problem. A true architect doesn't choose the "best" pattern—they build a system that can change.

"The flexibility of an architecture is measured not by the number of patterns, but by the ease of change."

A Test: The Level of Architectural Thinking in a Java Developer


🌐 На русском
Total Likes:0
My social media channel
By sending an email, you agree to the terms of the privacy policy

Useful Articles:

Go vs Java - memory model comparison: happens-before, visibility, reorder, synchronization events, write/read barriers
The memory model is a layer between the program and the processor. Modern CPUs aggressively optimize execution: instructions can be reordered, data can be stored in core caches, and operations can be ...
Go vs. Java - Comparing Memory Models - Part 2: Atomic Operations, Preemption, Defer/Finally, Context, Escape Analysis, GC, False Sharing
Atomic operations Atomic operations ensure correct execution of variable operations without race conditions, guaranteeing a happens-before between reads and writes. Go example: import "sync/atomic" va...
Java v25: Choosing the Right Multithreading for Any Task
Introduction The Java world is rapidly evolving, and with each version, new tools are emerging for effectively working with multithreading, collections, and asynchrony. Java 25 brings powerful feature...

New Articles:

Generics, Reflection and Channels - Go vs Java | Types - Language
In this article we will analyze advanced type system features in Go: generics (type parameters), reflection, and channel types for concurrency. We will compare Go and Java approaches, so Java develope...
Let's look at: Trace, Profiling, Integration Testing, Code Coverage, Mocking, Deadlock Detection in Go vs Java | Testing, Debugging and Profiling
Series: Go for Java Developers — analysis of trace, profiling and testing In this article we will analyze tools and practices for testing, debugging and profiling in Go. For a Java developer this wil...
Let's Break It Down: Rate Limiter, Non-Blocking Operations, and Scheduler: Go vs. Java | Concurrency Part 4
This article is dedicated to understanding the principles of concurrency and synchronization in Go and Java. We ll cover key approaches such as rate-limiter, non-blocking operations, and task scheduli...
Fullscreen image