Hexagonal Architecture, DDD and CQRS in Practice
Building Play The Event required more than a simple layered architecture. The complexity of event management, with its numerous business rules, state transitions, and inter-entity relationships, demanded a robust architectural foundation that could evolve without accumulating technical debt.
This article details the three architectural patterns at the core of Play The Event: Hexagonal Architecture (Ports & Adapters), Domain-Driven Design (DDD), and Command Query Responsibility Segregation (CQRS). We will examine how each pattern is implemented, how they complement each other, and why they were chosen.
What You'll Learn
- The 4-layer architecture and the role of each layer
- Domain, Application, Infrastructure, and Interfaces layers in detail
- DDD tactical patterns: Aggregate Root, Value Objects, Factory, Repository, Strategy
- CQRS: separating command and query responsibilities
- How the layers interact through well-defined boundaries
Why Not a Simple Layered Architecture?
A traditional MVC or 3-tier architecture works well for CRUD applications. However, Play The Event has domain logic that goes far beyond simple data manipulation. Consider just a few of the business rules involved:
- An event transitions through multiple states (Draft, Published, Active, Completed, Cancelled)
- Participants have different roles with different permissions
- RSVP responses trigger cascading updates to budgets and capacity counts
- Expense splitting must handle multiple currencies and calculation methods
- Trip coordination involves logistics dependencies between participants
The Problem with Traditional Layered Architecture
In a standard layered architecture, business logic tends to leak into controllers and services. Over time, the "service layer" becomes a god class handling everything from validation to persistence. Hexagonal Architecture prevents this by placing the domain at the center with no outward dependencies.
The 4-Layer Architecture
Play The Event organizes its codebase into four distinct layers, each with a clear responsibility and strict dependency rules. Dependencies always point inward, from outer layers toward the domain core.
DEPENDENCY DIRECTION: Outside → Inside
┌─────────────────────────────────────────────┐
│ INTERFACES LAYER (Controllers, REST API) │
│ ┌─────────────────────────────────────┐ │
│ │ INFRASTRUCTURE LAYER (JPA, Email) │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ APPLICATION LAYER (CQRS) │ │ │
│ │ │ ┌─────────────────────┐ │ │ │
│ │ │ │ DOMAIN LAYER │ │ │ │
│ │ │ │ (Entities, Rules) │ │ │ │
│ │ │ └─────────────────────┘ │ │ │
│ │ └─────────────────────────────┘ │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
Rule: Inner layers NEVER depend on outer layers.
The Domain layer has ZERO framework dependencies.
Layer 1: The Domain Layer
The domain layer is the heart of the system. It contains the business entities, value objects, domain events, repository interfaces (ports), and domain services. This layer is framework-agnostic: no Spring annotations, no JPA annotations, no external dependencies.
domain/
├── model/
│ ├── event/
│ │ ├── Event.java // Aggregate Root
│ │ ├── EventStatus.java // Enum: state machine
│ │ ├── EventType.java // Value Object
│ │ └── EventSettings.java // Value Object
│ ├── participant/
│ │ ├── Participant.java // Entity
│ │ ├── ParticipantRole.java // Enum
│ │ └── RsvpStatus.java // Enum
│ ├── expense/
│ │ ├── Expense.java // Aggregate Root
│ │ └── Money.java // Value Object
│ └── user/
│ ├── User.java // Aggregate Root
│ ├── UserId.java // Value Object
│ └── Email.java // Value Object
├── repository/
│ ├── EventRepository.java // Port (interface)
│ ├── ParticipantRepository.java // Port (interface)
│ └── UserRepository.java // Port (interface)
├── service/
│ ├── EventDomainService.java // Domain logic
│ └── ExpenseSplitter.java // Strategy pattern
└── event/
├── EventCreatedEvent.java // Domain event
└── ParticipantJoinedEvent.java // Domain event
Layer 2: The Application Layer
The application layer orchestrates use cases. It does not contain business rules (those belong in the domain), but it coordinates the flow of data between the domain and infrastructure layers. This is where CQRS is applied.
application/
├── command/
│ ├── CreateEventCommand.java
│ ├── CreateEventCommandHandler.java
│ ├── UpdateRsvpCommand.java
│ ├── UpdateRsvpCommandHandler.java
│ ├── AddExpenseCommand.java
│ └── AddExpenseCommandHandler.java
├── query/
│ ├── GetEventByIdQuery.java
│ ├── GetEventByIdQueryHandler.java
│ ├── ListParticipantsQuery.java
│ └── ListParticipantsQueryHandler.java
├── dto/
│ ├── EventDto.java
│ ├── ParticipantDto.java
│ └── ExpenseDto.java
└── mapper/
├── EventMapper.java
└── ParticipantMapper.java
Layer 3: The Infrastructure Layer
The infrastructure layer provides concrete implementations for the ports (interfaces) defined in the domain layer. This is where JPA repositories, email services, external API clients, and database configurations live.
Key Infrastructure Components
- JPA Adapters: Implement domain repository interfaces with Spring Data JPA
- Email Service: Sends notifications using SMTP or third-party providers
- File Storage: Handles document uploads and retrieval
- Flyway Migrations: Manages database schema versioning
- Cache Configuration: Caffeine cache for frequently accessed data
Layer 4: The Interfaces Layer
The outermost layer handles HTTP communication. REST controllers receive requests, validate input, delegate to the application layer, and return responses. This layer also includes security filters, exception handlers, and API documentation configuration.
DDD Tactical Patterns
Domain-Driven Design provides the vocabulary and patterns for modeling complex business logic. Play The Event uses several tactical DDD patterns extensively.
Aggregate Root
An Aggregate Root is the entry point to a cluster of related entities. External code can only interact with the aggregate through its root, which enforces all invariants and business rules.
public class Event {
private EventId id;
private String name;
private EventStatus status;
private List<Participant> participants;
private Money budget;
// Only the Aggregate Root can add participants
public Participant addParticipant(User user, ParticipantRole role) {
if (this.status != EventStatus.PUBLISHED
&& this.status != EventStatus.ACTIVE) {
throw new EventNotAcceptingParticipantsException(this.id);
}
if (hasReachedCapacity()) {
throw new EventFullException(this.id);
}
Participant participant = new Participant(user, role);
this.participants.add(participant);
return participant;
}
// State transitions are controlled
public void publish() {
if (this.status != EventStatus.DRAFT) {
throw new InvalidStateTransitionException(
this.status, EventStatus.PUBLISHED);
}
this.status = EventStatus.PUBLISHED;
}
}
Value Objects
Value Objects are immutable objects defined by their attributes rather than an identity. They encapsulate validation rules and domain concepts.
public record Money(BigDecimal amount, Currency currency) {
public Money {
if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException(
"Amount must be non-negative");
}
Objects.requireNonNull(currency, "Currency is required");
}
public Money add(Money other) {
requireSameCurrency(other);
return new Money(this.amount.add(other.amount), this.currency);
}
public Money subtract(Money other) {
requireSameCurrency(other);
return new Money(this.amount.subtract(other.amount), this.currency);
}
private void requireSameCurrency(Money other) {
if (!this.currency.equals(other.currency)) {
throw new CurrencyMismatchException(
this.currency, other.currency);
}
}
}
Factory Pattern
Factories encapsulate the creation logic for complex aggregates, ensuring all invariants are satisfied at construction time. Play The Event uses factories for events, participants, and expenses.
Repository Pattern
Repository interfaces are defined in the domain layer (ports) and implemented in the infrastructure layer (adapters). This inversion of dependencies keeps the domain decoupled from the persistence mechanism.
Strategy Pattern
The Strategy pattern is used for expense splitting, where different algorithms (equal split, percentage-based, custom amounts) can be selected at runtime depending on the organizer's preference.
CQRS: Commands and Queries
CQRS separates the read and write sides of the application. Commands modify state and return minimal data. Queries retrieve data without side effects. This separation brings several advantages to Play The Event.
Commands (Write Side)
- CreateEventCommand
- UpdateEventCommand
- PublishEventCommand
- AddParticipantCommand
- UpdateRsvpCommand
- AddExpenseCommand
- CheckInParticipantCommand
Queries (Read Side)
- GetEventByIdQuery
- ListUserEventsQuery
- GetParticipantListQuery
- GetEventBudgetQuery
- GetTripDetailsQuery
- GetEventStatisticsQuery
- SearchEventsQuery
public class CreateEventCommandHandler {
private final EventRepository eventRepository;
private final EventFactory eventFactory;
public EventId handle(CreateEventCommand command) {
// 1. Create the aggregate via factory
Event event = eventFactory.create(
command.name(),
command.description(),
command.startDate(),
command.endDate(),
command.organizerId()
);
// 2. Persist through repository port
eventRepository.save(event);
// 3. Return the identifier
return event.getId();
}
}
CQRS Without Event Sourcing
Play The Event uses CQRS without Event Sourcing. Commands and queries share the same database but use different models (write model vs. read optimized DTOs). This provides the organizational benefits of CQRS without the complexity of maintaining an event store.
How the Layers Interact
A typical request flows through all four layers. Here is the flow for creating a new event, from HTTP request to database persistence.
REQUEST FLOW: POST /api/events
1. INTERFACES → EventController.createEvent(request)
├── Validates HTTP request body
├── Maps to CreateEventCommand
└── Delegates to Application Layer
2. APPLICATION → CreateEventCommandHandler.handle(command)
├── Orchestrates the use case
├── Calls Domain Factory to create Event
└── Calls Repository Port to persist
3. DOMAIN → EventFactory.create(...)
├── Validates business rules
├── Creates Event aggregate with DRAFT status
└── Returns fully constructed Event
4. INFRASTRUCTURE → JpaEventRepository.save(event)
├── Maps domain entity to JPA entity
├── Persists to MySQL via Hibernate
└── Returns saved entity
5. RESPONSE → 201 Created + EventDto
Benefits of This Architecture
What This Architecture Enables
- Testability: The domain layer can be tested in isolation with no framework dependencies
- Flexibility: Swapping the database from MySQL to PostgreSQL requires only infrastructure changes
- Maintainability: Business rules live in one place and are easy to find and modify
- Scalability: CQRS allows independent optimization of read and write paths
- Team collaboration: Different developers can work on different layers simultaneously
In the Next Article
With the architectural foundation established, the next article dives into the domain model itself: the 86 entities that power Play The Event, the core aggregates, the event lifecycle, participant roles, and the value objects that enforce business rules at every level.
Key Takeaways
- Hexagonal Architecture keeps the domain independent of frameworks and infrastructure
- The 4-layer structure enforces clear boundaries and inward-only dependencies
- DDD tactical patterns (Aggregate Root, Value Objects, Factory, Repository, Strategy) model complex business logic
- CQRS separates read and write operations for better organization and performance
- The architecture enables testability, flexibility, and long-term maintainability







