Skip to content

Latest commit

 

History

History
331 lines (281 loc) · 9.09 KB

File metadata and controls

331 lines (281 loc) · 9.09 KB

Hexagonal Architecture - Deep Dive

Core Concepts

Dependency Rule

Dependencies flow inward only:

Adapter Layer → Application Layer → Domain Layer
  • Domain has NO dependencies (pure Java)
  • Application depends only on Domain
  • Adapters depend on Application and Domain
  • Infrastructure wires everything together

Layer Details

🔵 Domain Layer (Pure Business Logic)

Location: domain/

Contains:

  • Entities with business rules
  • Domain services for complex logic
  • Value objects and enums

Characteristics:

  • No framework dependencies
  • Pure POJOs and business logic
  • Can be tested without any infrastructure
// Domain entity with business rules
public class Payment {
  private String id;
  private BigDecimal amount;
  
  public boolean canBeCancelled() {
    return status == PaymentStatus.PENDING;
  }
}

// Domain service for complex business rules
public class PaymentDomainService {
  public PaymentValidationResult validatePayment(Payment payment) {
    // Pure business validation logic
  }
}

🟢 Application Layer (Use Cases & Orchestration)

Location: application/

Components:

  1. Ports (Interfaces)

    • ports/in/ - Input ports (what the application does)
    • ports/out/ - Output ports (what the application needs)
  2. Handlers (handler/)

    • Receive requests from controllers
    • Map DTOs to commands
    • Call use case services
  3. Services (service/)

    • Implement use cases (input ports)
    • Orchestrate domain services
    • Use output ports for I/O

Flow:

Controller → Handler → Service (Use Case) → Domain Service
                         ↓
                    Output Ports → Adapters
// Input port (use case interface)
public interface CreatePaymentUseCase {
  Mono<Payment> createPayment(CreatePaymentCommand command);
}

// Output port (what we need from external systems)
public interface SavePaymentPort {
  Mono<Payment> save(Payment payment);
}

// Service implements use case
@Service
public class CreatePaymentService implements CreatePaymentUseCase {
  private final PaymentDomainService domainService;
  private final SavePaymentPort savePaymentPort;
  
  public Mono<Payment> createPayment(CreatePaymentCommand command) {
    return buildPayment(command)
      .flatMap(domainService::validate)
      .flatMap(savePaymentPort::save);
  }
}

🟡 Adapter Layer (External World)

Location: adapter/

Inbound Adapters (adapter/in/)

  • Web Controllers - HTTP REST endpoints
  • Kafka Consumers - Event-driven triggers
  • Scheduled Jobs - Time-based triggers

Outbound Adapters (adapter/out/)

  • Database Repositories - R2DBC persistence
  • REST Clients - HTTP calls to other services
  • Kafka Producers - Event publishing
  • Redis Cache - Caching layer

✅ Correct Structure (Fixed):

adapter/in/web/
  ├── dto/           ✅ HTTP DTOs belong here
  ├── mapper/        ✅ HTTP mappers belong here
  └── controller/

adapter/out/db/
  ├── entity/        ✅ DB entities organized
  ├── mapper/        ✅ DB mappers organized
  └── repository/

adapter/out/restclient/
  ├── dto/           ✅ External service DTOs
  ├── mapper/        ✅ External service mappers
  └── client/

🟣 Infrastructure Layer (Configuration ONLY)

Location: infrastructure/

New Organized Structure:

infrastructure/
├─ config/                    # Core application configuration
│  ├─ ApplicationConfig.java
│  ├─ ObjectMapperConfig.java
│  ├─ ValidationConfig.java
│  ├─ ReactorContextConfig.java
│  └─ ProblemDetailsConfig.java
├─ persistence/               # Database & transaction configuration
│  ├─ R2dbcConfig.java
│  ├─ TransactionConfig.java
│  ├─ MigrationConfig.java
│  └─ IdGeneratorConfig.java
├─ messaging/                 # Kafka & event streaming
│  ├─ KafkaConfig.java
│  ├─ KafkaTopicConfig.java
│  └─ SerdeConfig.java
├─ cache/                     # Redis & caching policies
│  ├─ RedisConfig.java
│  └─ CachePolicy.java
├─ http/                      # WebClient & HTTP configuration
│  ├─ WebClientConfig.java
│  ├─ HttpClientObservability.java
│  └─ CorsAndRateLimitConfig.java
├─ security/                  # Security & authentication
│  ├─ SecurityConfig.java
│  ├─ JwtConfig.java
│  └─ DataMaskingConfig.java
├─ observability/            # Monitoring & tracing
│  ├─ MetricsConfig.java
│  ├─ TracingConfig.java
│  └─ LoggingConfig.java
├─ resilience/               # Fault tolerance patterns
│  ├─ ResilienceConfig.java
│  └─ BackoffPolicy.java
├─ errorhandling/            # Global error handling
│  ├─ GlobalErrorAttributes.java
│  └─ GlobalExceptionHandler.java
└─ profiles/                 # Environment configurations
   ├─ application.yml
   ├─ application-dev.yml
   ├─ application-staging.yml
   └─ application-prod.yml

Contains:

  • Config - Core application beans and configuration
  • Persistence - Database, transactions, migrations, ID generation
  • Messaging - Kafka configuration, topics, serialization
  • Cache - Redis configuration and caching policies
  • HTTP - WebClient, observability, CORS, rate limiting
  • Security - Authentication, authorization, data masking
  • Observability - Metrics, tracing, logging configuration
  • Resilience - Circuit breakers, retry policies, bulkheads
  • Error Handling - Global exception handling and error responses
  • Profiles - Environment-specific configurations

Does NOT contain:

  • ❌ Business DTOs
  • ❌ Mappers (MapStruct interfaces)
  • ❌ Entities
// ✅ Good: Infrastructure config
@Configuration
public class WebClientConfig {
  @Bean
  public WebClient accountServiceClient() {
    return WebClient.builder()
      .baseUrl("http://account-service")
      .build();
  }
}

Request Flow Example

HTTP POST /api/v1/payments

1. PaymentController (adapter/in/web)
   - Receives HTTP request
   - Validates request body
   ↓
2. CreatePaymentRequestHandler (application/handler)
   - Maps DTO to Command
   - Calls use case
   ↓
3. CreatePaymentService (application/service)
   - Implements CreatePaymentUseCase
   - Orchestrates business flow
   ↓
4. PaymentDomainService (domain/service)
   - Validates business rules
   - Calculates fees, etc.
   ↓
5. SavePaymentPort → PaymentPersistenceAdapter (adapter/out/db)
   - Saves to database via R2DBC
   ↓
6. PublishEventPort → PaymentEventProducer (adapter/out/kafka)
   - Publishes event to Kafka
   ↓
7. Response flows back: Entity → Domain → DTO → HTTP

Best Practices

Do's ✅

  • Keep domain layer pure (no framework dependencies)
  • Define clear port interfaces
  • Use commands for input, events for output
  • Map at adapter boundaries (Entity ↔ Domain ↔ DTO)
  • Test business logic in isolation

Don'ts ❌

  • Don't expose domain entities directly via API
  • Don't let domain depend on frameworks
  • Don't put business logic in controllers
  • Don't bypass ports (direct adapter access)
  • Don't use DTOs in domain/application layers

Reactive Patterns

Avoid Deep Nesting

// ❌ Bad: Nested flatMap
return a.flatMap(x ->
  b(x).flatMap(y ->
    c(x, y).flatMap(z ->
      d(x, y, z)
    )
  )
);

// ✅ Good: Use zipWhen, delayUntil, then
return a
  .zipWhen(this::b)
  .flatMap(tuple -> c(tuple.getT1(), tuple.getT2()))
  .delayUntil(this::d);

Error Handling

return createPayment(command)
  .doOnSuccess(p -> log.info("Payment created: {}", p.getId()))
  .doOnError(e -> log.error("Failed: {}", e.getMessage()))
  .onErrorMap(SQLException.class, 
    e -> new CustomException(CommonCode.DB_ERROR, e.getMessage()));

Benefits

1. Testability

// Unit test domain (no framework)
@Test
void shouldValidatePayment() {
  PaymentDomainService service = new PaymentDomainService();
  Payment payment = Payment.builder().amount(BigDecimal.ZERO).build();
  
  assertThat(service.validatePayment(payment).isValid()).isFalse();
}

// Integration test with mocked ports
@Test
void shouldCreatePayment() {
  SavePaymentPort mockPort = mock(SavePaymentPort.class);
  CreatePaymentService service = new CreatePaymentService(mockPort, ...);
  
  // Test business logic without database
}

2. Flexibility

  • Swap PostgreSQL for MongoDB: Only change adapter
  • Replace REST with gRPC: Add new inbound adapter
  • Add caching: Create cache adapter implementing ports

3. Maintainability

  • Clear separation of concerns
  • Business logic isolated from framework
  • Easy to understand and modify

Summary

Hexagonal Architecture = Ports and Adapters

  • Ports = Interfaces (what we need/provide)
  • Adapters = Implementations (how we do it)
  • Domain = Pure business logic (no dependencies)
  • Application = Use cases (orchestration)

This separation allows you to:

  1. Test business logic without infrastructure
  2. Swap implementations easily
  3. Keep code maintainable and clean
  4. Follow SOLID principles naturally