πͺπΈ VersiΓ³n en EspaΓ±ol
This project is a practical and didactic example designed to explain how to integrate advanced software engineering concepts into a Java application with Spring Boot.
π€ For AI Agents: If you are an AI agent working with this project, consult
AGENTS.mdfor specific instructions, commands, architecture rules, and best practices.
β¨ Powered by AI: This project has been created and refactored with the help of the Gemini 3 assistant.
The core of the software is the Domain (business logic), and it must be isolated from technical details.
- Rich Entities:
Courseis not just 'data', it contains logic and validations. - Value Objects:
CourseIdandCourseName. We avoid using primitive types (String,int) for domain concepts. This prevents errors (e.g., passing a name where an ID is expected) and encapsulates validation rules.
Divides the application into Inside (Domain + Application) and Outside (Infrastructure).
- Ports: Interfaces defined in the domain (
CourseRepository). The domain states what it needs, but not how it is done. - Adapters: Implementations in the infrastructure layer.
- Driver (Input):
CoursePostController(REST API). - Driven (Output):
InMemoryCourseRepository(Database).
- Driver (Input):
Readable and maintainable code.
- Expressive Names: Classes and methods say exactly what they do.
- Short Methods: Single responsibility (SOLID).
- Semantic Constructors: Use of static factory methods (
Course.create(...)) instead of complex public constructors.
Separation of Write (Command) and Read (Query) operations.
- Write Side (Command): Optimized for consistency and business rules.
CreateCourseCommand: User intention.CreateCourseCommandHandler: Logic that modifies state.
- Read Side (Query): Optimized for the client view.
FindCourseQueryHandler: Fetches data and returns plain DTOs (CourseResponse). We never expose the Domain Entity directly in reading to avoid coupling the API to internal rules.
Reusable code between different Bounded Contexts (modules).
- Identifier: Abstract base class (
shared.domain.Identifier) that encapsulates UUID logic and validation.CourseIdinherits from it, avoiding code duplication if we createStudentIdtomorrow.
Transformation of domain exceptions into coherent HTTP responses.
- GlobalExceptionHandler: Uses Spring's
@ControllerAdviceto capture exceptions:IllegalArgumentException(Validation) -> 400 Bad Request.CourseNotFound(Not exists) -> 404 Not Found.
Development guided by tests at all levels.
- Domain Unit:
CourseNameTestandCourseIdTestshield business rules (validations, nulls...). - Application Unit:
CreateCourseCommandHandlerTestverifies orchestration (mocks). - API Integration:
CoursePostControllerTestverifies the HTTP web layer simulating real requests.
Mechanism to decouple side effects (send email, logs, analytics).
- Core:
AggregateRoot(Shared Kernel) allows entities to record what happened (record()). - Event:
CourseCreatedEventcaptures that a course was created. - Publication: The Handler retrieves events from the aggregate (
pullDomainEvents()) and publishes them to theEventBus. - Infrastructure:
SpringApplicationEventBususes Spring's native system to propagate them.
Total decoupling from the framework.
- Problem: Using
@Serviceor@Autowiredinside Handlers dirties application code with Spring dependencies. - Solution: Handlers (
CreateCourseCommandHandler) are pure POJOs (Plain Old Java Objects) without annotations. - Configuration: The
CourseModuleDependencyConfigclass in the infrastructure layer is the only one that knows about Spring and declares@Beans, manually injecting repositories and buses. This allows migrating to another framework (Quarkus, Micronaut) without touching business logic.
Protection of the domain layer from invalid data at the entrance.
- Bean Validation (JSR-380): Use of standard annotations (
@NotBlank,@Size, etc.) in input DTOs (CourseRequest). - Fail Fast: The controller rejects invalid requests before they touch the domain or application.
- Global Error Handling:
GlobalExceptionHandlerintercepts validation errors (MethodArgumentNotValidException) and returns a structured response (JSON with field and error) and HTTP code 400 Bad Request.
Robust testing pyramid strategy.
- Architecture Tests (ArchUnit): Refined rules ensuring domain isolation but allowing flexibility in infrastructure naming.
- Event Integration Tests:
WelcomeEmailIntegrationTestlifts the Spring context to verify the full async flow: Event Publication -> Listener -> Use Case -> Port -> Adapter (Mock). - Serialization Tests:
DomainEventSerializationTestensures domain events can be correctly converted to JSON, simulating a real messaging scenario (Kafka/RabbitMQ).
Production-ready features (Cloud/Kubernetes).
- Health Checks: Endpoint
/actuator/healthso orchestrators know if the pod is alive. - Metrics: Endpoint
/actuator/metricsfor monitoring (Prometheus, Grafana).
Use of Java 21+ features.
- Records: DTOs (
CreateCourseCommand,CourseResponse) arerecordinstead ofclass. This eliminates boilerplate (getters, equals, hashCode, toString) and makes code more concise and safe (immutability by default).
Packet structure reflects Business (Modules) and not technical layers.
src/main/java/com/hexagonal/demo
βββ courses <-- BOUNDED CONTEXT MODULE
β βββ application
β β βββ create <-- WRITE SIDE
β β β βββ CreateCourseCommand.java
β β β βββ CreateCourseCommandHandler.java
β β βββ find <-- READ SIDE
β β βββ CourseResponse.java
β β βββ FindCourseQueryHandler.java
β β βββ CourseNotFound.java
β βββ domain
β β βββ Course.java (Aggregate Root)
β β βββ CourseCreatedEvent.java (Domain Event)
β β βββ CourseId.java
β β βββ CourseRepository.java
β βββ infrastructure
β βββ api
β β βββ CoursePostController.java
β βββ persistence
β βββ InMemoryCourseRepository.java
βββ shared <-- SHARED KERNEL
βββ domain
β βββ AggregateRoot.java
β βββ Identifier.java
β βββ bus/event (Event Ports)
βββ infrastructure
βββ bus/event/spring (Spring Adapter)
### πΊοΈ Architecture Map
Visual representation of dependency flow and domain isolation.
```mermaid
graph TD
subgraph Infrastructure [Infrastructure (Outside)]
style Infrastructure fill:#ffdfba,stroke:#333,stroke-width:2px
API[API REST Controller]
DB[H2 Persistence Adapter]
EmailAdapter[Fake Email Sender]
end
subgraph Application [Application (Orchestration)]
style Application fill:#ffffba,stroke:#333,stroke-width:2px
CMD[Create Course Handler]
QUERY[Find Course Handler]
end
subgraph Domain [Domain (Core)]
style Domain fill:#baffc9,stroke:#333,stroke-width:4px
Course[Course Entity]
RepoPort[<<Interface>>\nCourseRepository]
EmailPort[<<Interface>>\nEmailSender]
end
%% Dependency Flow (Outside -> Inside)
API --> CMD
API --> QUERY
CMD --> Course
CMD --> RepoPort
QUERY --> RepoPort
%% Implementation (Inversion of Control)
DB -.->|implements| RepoPort
EmailAdapter -.->|implements| EmailPort
%% Events
Course -- emits --> Event[CourseCreatedEvent]
Event --> EmailAdapter
- HTTP Request: User sends a
POST /courses. - Input Adapter:
CoursePostController->CreateCourseCommand. - Application Service:
CreateCourseCommandHandler.- Creates
Course. Course(Domain) is created and internally recordsCourseCreatedEvent.- Persists in Repository.
- Publishes events to
EventBus.
- Creates
- Side Effects (Decoupling):
WelcomeEmailSubscriberlistens toCourseCreatedEvent.- Invokes
SendWelcomeEmailUse Case (Application). - This uses
EmailSenderPort (Domain). - Finally,
FakeEmailSenderAdapter (Infra) executes the action (log).
- HTTP Request: Receives
GET /courses/uuid. - Query Handler:
FindCourseQueryHandlersearches in repository. - Response:
- If exists: Converts
Course->CourseResponse(DTO) and returns 200 OK. - If not exists: Throws
CourseNotFound.GlobalExceptionHandlercaptures it and returns 404.
- If exists: Converts
To simplify development:
- Makefile: Shortcuts for common commands (
make run,make test). - requests.http: Executable file in IntelliJ/VSCode to test API without leaving the editor.
make help # See all commandsLiving API documentation.
- Accessible at
http://localhost:8080/swagger-ui.htmlwhen app starts. - Allows testing endpoints visually.
-
Start:
./mvnw spring-boot:run
-
Check Health (Actuator):
curl http://localhost:8080/actuator/health # Response: {"status":"UP"} -
Validation Test (Fail Fast): Try to create a course with a short name:
curl -X POST http://localhost:8080/courses \ -H "Content-Type: application/json" \ -d '{"id": "valid-uuid", "name": "Hi", "duration": "15m"}'You will receive a 400 Bad Request with validation error details.
-
Create a Course (Write + Event):
curl -X POST http://localhost:8080/courses \ -H "Content-Type: application/json" \ -d '{"id": "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", "name": "Hexagonal Master", "duration": "15m"}'You will see in console that the course is saved AND the
WelcomeEmailSubscriberlog simulating email sending. -
Search Preloaded Course (Read): The system automatically preloads 2 example courses at startup. You can query them directly:
# Course 1: Hexagonal Architecture Masterclass curl http://localhost:8080/courses/1a9b456b-e85b-4b2a-a92c-d9a2c6d4838f # Course 2: Domain-Driven Design Tactical Patterns curl http://localhost:8080/courses/2b9b456b-e85b-4b2a-a92c-d9a2c6d4838f
π‘ Tip: You can also create your own course with the command in step 4 and then find it by its UUID.
β οΈ Important: IDs must be valid UUIDs (format:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx). If you try to use a simple ID like1orabc, you will receive a validation error.
The application includes an optimized multi-stage Dockerfile.
-
Build the image:
docker build -t hexagonal-architecture-masterclass . -
Run the container:
docker run -p 8080:8080 hexagonal-architecture-masterclass
API will be available at
http://localhost:8080.
Demonstration of adapter change (Persistence) without affecting the domain.
- Implementation:
JpaCourseRepositoryusesJpaRepositoryand mapsCourseEntity(infrastructure) toCourse(domain). - H2 Console: Accessible at
http://localhost:8080/h2-console- JDBC URL:
jdbc:h2:mem:testdb - User:
sa - Password: (empty)
- JDBC URL:
To demonstrate real portability of hexagonal architecture, we include a docker-compose.yml.
It spins up:
- PostgreSQL 15: Real production database.
- Hexagonal App: Configured (via env vars) to connect to Postgres instead of H2.
make compose-upThis demonstrates that Domain layer DOES NOT change even if we change infrastructure from H2 (Memory) to Postgres (Disk).
We document the "why" of our technical decisions using ADR format.
Project includes JaCoCo to measure test coverage.
make coverageThis will run tests and automatically open a detailed web report in your browser.
To demonstrate real decoupling, we have configured:
@Async: Email sending happens in a separate thread.- Simulated Latency:
FakeEmailSenderhas an intentionalThread.sleep(2000). - Result: Even though sending email takes 2 seconds, HTTP API responds in milliseconds (
201 Created). User doesn't wait.
Project includes optimizations to improve startup time:
- Lazy Initialization: Beans initialized only when needed
- JMX Disabled: Reduces overhead in development
- JPA Optimized:
open-in-view=falseto avoid lazy loading issues - Specific Component Scan: Only scans necessary packages
Result: Significantly reduced startup time without compromising functionality.
To deepen these concepts, here are some of the best resources:
- Alistair Cockburn - Hexagonal Architecture (Original source)
- Herberto GraΓ§a - Ports & Adapters Architecture (Excellent visual explanation)
- Martin Fowler - Domain Driven Design
- Domain-Driven Design Reference (Eric Evans) (Official free summary)
- Clean Code: A Handbook of Agile Software Craftsmanship (Robert C. Martin)
- Refactoring.guru - Clean Code & Design Patterns
- Vaughn Vernon - Domain Events
- Spring Events - Baeldung (Technical implementation in Spring)
# Run all tests
./mvnw test15 tests are executed validating from pure domain logic to event integration and architecture rules.