- Introduction
- 1. The Evolution of Architectures
- 2. CQRS and Event Sourcing
- 3. DDD — Designing with Meaning
- 4. Reactive Architectures
- 5. How to Choose an Approach
- Pitfalls
- Event Sourcing - Explodes Complexity During Migration
- DDD — Requires Discipline in Naming Entities (and More)
- Microservices Increase Infrastructure Costs
- Conclusion
- A Test: The Level of Architectural Thinking in a Java Developer
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."
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
- 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.
- Upcasters / Event Migrators. Implement a layer that transforms events on the fly before deserialization or performs offline migration in a separate, safe step.
- Snapshots. Store periodic snapshots of the aggregate state—this reduces the number of replays and provides a checkpoint for changes.
- Testable Evolution. Write integration tests that replay real past events (test fixtures) and verify the correctness of upcasting.
- 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
- 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).
- Bounded Contexts and Context Map. Explicitly describe boundaries: which terms are local, which are translated via the Anti-Corruption Layer (ACL).
- 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."
- Anti-corruption layer. When integrating with external/other contexts, use adapters that translate foreign terms into your language and back.
- 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
- Start with a modular monolith. Until there's a real need for separation, keep the code in a single repository with clearly defined modules.
- 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.
- Observability by default. Structured message logging, metrics, distributed tracing (OpenTelemetry), alerts, and runbooks are mandatory.
- Contracts and integration tests. Consumer-Driven Contracts (Pact), e2e and integration tests for critical paths. Mocking is no substitute for contract tests.
- Network resilience patterns. Circuit breakers, retry with backoff, bulkheads, and timeouts should be built into client libraries by default.
- 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
Useful Articles:
New Articles: