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:

Slice internals in Go ↔ Java: from header to hidden allocations
Slice in Go is one of those structures that looks simple, but under the hood behaves like a little clever beast. If you are a Java developer, you might think: "well, this is just an ArrayList." And th...
Low-level mechanisms | Go ↔ Java
In this article, we will examine the key low-level mechanisms of Go, comparing them to similar tools in Java. The article is intended for Java developers who want to gain a deeper understanding of Go,...
Modern approach to parallelism in Java - Fork/Join Framework, CompletableFuture, and virtual threads (Project Loom)
Preface The world of software has long ceased to be a calm ocean: today it is a turbulent ecosystem where every millisecond of application response can cost a company customers, reputation, or money. ...

New Articles:

Concurrency is not about “starting many threads”. It’s about agreements between them. Imagine a restaurant kitchen: — cooks (threads / goroutines) — orders (tasks) — and the main question: how do th...
When HashMap starts killing production: the engineering story of ConcurrentHashMap
Imagine a typical production service. 32 CPU hundreds of threads configuration / session / rate limits cache tens of thousands of operations per second And somewhere inside — a regular Map. At first...
Zero Allocation in Java: what it is and why it matters
Zero Allocation — is an approach to writing code in which no unnecessary objects are created in heap memory during runtime. The main idea: fewer objects → less GC → higher stability and performance. ...
Fullscreen image