Quarkus Insights #248: Introduction to Domain-Driven Design and Hexagonal Architecture

This summary was generated using AI, reviewed by humans - watch the video for the full story.

Quarkus Insights #248: Introduction to Domain-Driven Design and Hexagonal Architecture

In episode 248 of Quarkus Insights, Jeremy Davis, a Solutions Architect with extensive experience in the JBoss ecosystem, provided an in-depth introduction to Domain-Driven Design (DDD) and Hexagonal Architecture using Quarkus.

Why DDD is Having a Moment

Jeremy opened by noting that DDD seems to be experiencing renewed interest, with multiple presentation requests recently. He theorizes this resurgence relates to the rise of AI-assisted development and agentic systems, where DDD’s structured approach to organizing business logic makes it easier for AI tools to understand and generate code.

The Core Principles of Domain-Driven Design

Ubiquitous Language

One of Jeremy’s favorite aspects of DDD is the emphasis on ubiquitous language - a shared vocabulary between developers and business stakeholders. He illustrated this with the restaurant industry example: "86 ketchup" means "we’re out of ketchup" to anyone who’s worked in restaurants, but is completely nonsensical to outsiders.

Since every industry has its own language, DDD encourages teams to:

  • Identify and document domain-specific terminology

  • Use that language consistently in code, conversations, and documentation

  • Refine the language iteratively with each sprint or development cycle

Domains and Subdomains

For the demo, Jeremy built an application for scheduling Quarkus Insights episodes, with several subdomains:

  • Programming: Managing episode content and scheduling

  • People: Managing hosts, presenters, and guests

  • Engagement: Handling viewer interactions and notifications

Each subdomain represents a distinct area of business concern with its own models and logic.

Building Blocks of DDD

Aggregates

Aggregates are the core business concepts that encapsulate business rules (invariants). In Jeremy’s example, the EpisodeAggregate serves as the root entity:

public class EpisodeAggregate {
    private EpisodeId id;
    private EpisodeTitle title;
    private String description;
    private EpisodeAirDate airDate;
    private Collection<DomainEvent> domainEvents;

    public static EpisodeAggregate schedule(
        EpisodeTitle title,
        String description,
        EpisodeAirDate airDate) {
        // Business logic here
        EpisodeAggregate episode = new EpisodeAggregate();
        episode.domainEvents.add(new EpisodeScheduledEvent());
        return episode;
    }
}

Key characteristics:

  • Aggregates are POJOs with no framework dependencies

  • All business logic lives in the aggregate

  • Aggregates maintain invariants (rules that must always be true)

  • You interact with the object graph through the aggregate root

Value Objects

Value objects encapsulate data with no identity. That is, a value object is defined entirely by its attributes, not by some unique ID. Examples include addresses, email addresses, or in Jeremy’s demo, episode titles:

public record EpisodeTitle(String value) {
    public EpisodeTitle {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("Episode title cannot be null or blank");
        }
        // Could add more validation:
        // - Must contain "Quarkus"
        // - Must contain "Insights"
        // - Must have episode number
    }
}

Value objects enable validation at creation time and make the domain model more expressive.

Domain Events

Domain events represent facts that the business cares about:

public record EpisodeScheduledEvent(
    EpisodeId episodeId,
    EpisodeTitle title,
    LocalDate airDate
) implements DomainEvent {
}

Events are:

  • Statements of fact (they have occurred)

  • Immutable

  • Can be replayed

  • Enable event-driven architecture

Unlike commands (which can be rejected), events represent things that have already happened.

Hexagonal Architecture

The Layered Approach

Jeremy demonstrated how to structure a DDD application using hexagonal architecture:

Infrastructure Layer (Adapters): - REST endpoints (incoming port) - Database repositories (outgoing port) - Message brokers (outgoing port) - DTOs for wire format

Application Layer: - Application services that orchestrate business logic - Command handlers - Domain services for logic that doesn’t fit in aggregates

Domain Layer: - Aggregates - Value objects - Domain events - Repository interfaces (not implementations)

Application Services

Application services orchestrate the workflow:

@ApplicationScoped
public class EpisodeApplicationService {

    @Inject
    EpisodeRepository repository;

    @Inject
    DomainEventPublisher eventPublisher;

    public EpisodeDTO scheduleEpisode(ScheduleEpisodeCommand command) {
        // 1. Validate title doesn't exist
        if (repository.titleExists(command.title())) {
            throw new IllegalArgumentException("Title already exists");
        }

        // 2. Create aggregate
        EpisodeAggregate episode = EpisodeAggregate.schedule(
            command.title(),
            command.description(),
            command.airDate()
        );

        // 3. Persist
        episode = repository.persist(episode);

        // 4. Publish events
        episode.getDomainEvents().forEach(eventPublisher::publish);

        return toDTO(episode);
    }
}

Separating Concerns

A critical aspect of hexagonal architecture is keeping the domain pure:

  • Aggregates have no knowledge of persistence frameworks

  • Repositories handle the translation between aggregates and entities

  • Mappers convert between domain objects and database entities

@ApplicationScoped
public class EpisodeRepository implements PanacheRepository<EpisodeEntity> {

    public EpisodeAggregate persist(EpisodeAggregate aggregate) {
        EpisodeEntity entity = toEntity(aggregate);
        persist(entity);
        return rehydrate(entity);
    }

    public static EpisodeAggregate rehydrate(EpisodeEntity entity) {
        return new EpisodeAggregate(
            entity.id,
            new EpisodeTitle(entity.title),
            entity.description,
            new EpisodeAirDate(entity.airDate)
        );
    }
}

The Trade-offs

More Code, Better Organization

Jeremy was upfront about the main drawback: you’ll write significantly more code with DDD compared to a simple CRUD application. The demo showed:

  • Value objects for each domain concept

  • Separate DTOs for REST endpoints

  • Commands for operations

  • Events for notifications

  • Mappers between layers

The Benefits

However, this additional code provides substantial benefits:

1. Maintainability: When inheriting code, business logic is easy to find - it’s all in the aggregates.

2. Testability: Aggregates are POJOs that can be tested with plain JUnit, no framework required.

3. Flexibility: Swapping persistence frameworks (e.g., from Hibernate to Firebase) only requires changing the repository implementation.

4. Clear boundaries: Logic doesn’t leak across layers or get scattered in event handlers.

5. AI-friendly: The structured approach makes it easier for AI tools to understand and generate code.

Collaboration Between Domains

Jeremy addressed a key question: how do domains interact?

Close Collaboration Pattern

When teams work closely together:

// People subdomain exposes an API
public interface PeopleAPI {
    Collection<PersonDTO> registerHosts(Collection<PersonDTO> hosts);
    Collection<PersonDTO> registerPresenters(Collection<PersonDTO> presenters);
}

// Episodes subdomain uses it via a domain service
@ApplicationScoped
public class PeopleDomainService {
    @Inject
    PeopleAPI peopleAPI;

    public Collection<PersonDTO> registerHosts(Collection<PersonDTO> hosts) {
        return peopleAPI.registerHosts(hosts);
    }
}

Anti-Corruption Layer

When integrating with external systems you don’t control, use an anti-corruption layer to translate between their model and yours, protecting your domain from external changes.

Transaction Boundaries

Important: Transaction boundaries should stay within each domain. If operations span multiple domains, consider:

  • Using eventual consistency with domain events

  • Implementing saga patterns for distributed transactions

  • Carefully evaluating if you’ve drawn domain boundaries correctly

Validation at Multiple Layers

Jeremy emphasized that validation occurs at different layers for different purposes:

REST Layer (DTOs): Basic validation (not null, not blank)

public record CreateEpisodeRequest(
    @NotBlank String title,
    @NotBlank String description,
    @NotNull LocalDate airDate
) {}

Domain Layer (Value Objects): Business rule validation

public record EpisodeAirDate(LocalDate value) {
    public EpisodeAirDate {
        if (value.isBefore(LocalDate.now())) {
            throw new IllegalArgumentException("Episode cannot air in the past");
        }
    }
}

Application Layer: Cross-aggregate validation

if (repository.titleExists(title)) {
    throw new IllegalArgumentException("Title already exists");
}

AI-Assisted DDD Development

Jeremy demonstrated using Claude AI to generate DDD boilerplate:

Skills and Specifications

He created:

  • Quarkus skills: Instructions for using Panache, REST endpoints, logging

  • DDD skills: Patterns for aggregates, value objects, repositories

  • Specification files: High-level requirements for the application

Results

Using AI with these skills, Jeremy generated a complete DDD application structure in "YOLO mode" (letting the AI run freely). While not perfect (it didn’t use Panache as instructed), it created:

  • Proper aggregate structure

  • Value objects with validation

  • Repository interfaces

  • Domain events

  • Application services

The key insight: DDD’s structured, boilerplate-heavy nature makes it ideal for AI generation, while keeping business logic centralized for human review.

Testing Architecture with ArchUnit

Jeremy mentioned ArchUnit, a library for testing architectural rules:

@Test
public void domainLayerShouldNotDependOnInfrastructure() {
    classes()
        .that().resideInPackage("..domain..")
        .should().onlyDependOnClassesThat()
        .resideInAnyPackage("..domain..", "java..")
        .check(importedClasses);
}

This helps enforce architectural boundaries automatically.

When to Use DDD

Jeremy’s guidance on when DDD makes sense:

Good fit: - Complex business logic - Long-lived applications - Multiple teams working on different domains - Need for clear boundaries and maintainability - AI-assisted development workflows

Overkill: - Simple CRUD applications - Prototypes or short-lived projects - Small teams with simple requirements

Resources and Next Steps

Jeremy offered to return for follow-up episodes covering:

  • State distribution across domains

  • Event sourcing and saga patterns

  • CQRS for read models

  • AI-assisted DDD workflows in depth

He also mentioned:

Key Takeaways for Developers

  1. DDD provides structure that makes business logic easy to find and maintain

  2. Hexagonal architecture keeps your domain pure and testable

  3. More code upfront pays dividends in maintainability

  4. Ubiquitous language bridges the gap between business and technology

  5. Value objects enable validation at creation time

  6. Domain events enable event-driven architecture naturally

  7. AI tools work well with DDD’s structured approach

  8. Transaction boundaries should stay within domains

  9. Validation happens at multiple layers for different purposes

  10. Not every application needs DDD - evaluate complexity first

Conclusion

Domain-Driven Design and Hexagonal Architecture provide powerful patterns for organizing complex business logic in Quarkus applications. While they require more upfront code, the benefits in maintainability, testability, and clarity make them valuable for long-lived, complex applications. The structured nature of DDD also makes it particularly well-suited for AI-assisted development, where boilerplate can be generated while keeping business logic centralized and reviewable.

The combination of Quarkus’s developer-friendly features (like Dev Mode, CDI, and Panache) with DDD patterns creates a powerful foundation for building maintainable, scalable applications.

Watch the full episode on the Quarkus YouTube channel and explore more at quarkus.io.