Aller au contenu

Hexagonal Architecture Refactoring Plan

Module: All Spring Modulith modules (starting with tenant, then identity) Type: Structural refactoring — no behavior changes Date: 2026-03-14 Status: Approved for implementation Prerequisite: Must complete before CP-003+ development


1. Motivation

  • Testability: UseCase classes take port interfaces in constructor. Unit test with mocks, no Spring context, no Testcontainers. Tests run in milliseconds.
  • Readability: port/in/ package immediately shows what a module does. One class = one workflow.
  • Multi-tenant isolation: @Qualifier("platformJdbcClient"), datasource routing, tenant context — all confined to adapter/out/persistence/. UseCases never see infrastructure concerns.
  • Spring Data JDBC synergy: Explicit data access + explicit ports = no magic, no surprises.

2. Target Package Structure

2.1 Tenant Module (template for all future modules)

com.altarys.papillon.platform.tenant/
├── domain/
│   ├── model/
│   │   ├── Tenant.java                           # Entity (keep @Table — pragmatic)
│   │   ├── TenantSettings.java                   # Entity (keep @Table + Persistable)
│   │   ├── TenantStatus.java                     # Enum
│   │   ├── TenantStateMachine.java               # Pure domain logic (unchanged)
│   │   ├── UpdateSettingsCommand.java             # NEW — domain command record
│   │   ├── TenantNotFoundException.java           # Exception
│   │   └── IllegalStateTransitionException.java   # Exception
│   ├── port/
│   │   ├── in/
│   │   │   ├── TransitionTenantUseCase.java       # Interface
│   │   │   └── UpdateTenantSettingsUseCase.java    # Interface
│   │   └── out/
│   │       ├── TenantPersistencePort.java          # Interface (platform DB)
│   │       └── TenantSettingsPersistencePort.java   # Interface (tenant DB)
│   └── usecase/
│       ├── TransitionTenantUseCaseImpl.java         # @Service — state transitions + events
│       └── UpdateTenantSettingsUseCaseImpl.java      # @Service — partial update + audit events
├── adapter/
│   ├── in/
│   │   └── web/
│   │       ├── TenantController.java               # Moved from api/
│   │       ├── TenantResponse.java                 # Moved from api/
│   │       ├── TenantSettingsResponse.java         # Moved from api/
│   │       ├── UpdateTenantSettingsRequest.java     # Moved from api/
│   │       └── TenantDtoMapper.java                # NEW — extracted from TenantService
│   └── out/
│       └── persistence/
│           ├── TenantPersistenceAdapter.java        # NEW — wraps platformJdbcClient
│           ├── TenantSettingsPersistenceAdapter.java # NEW — wraps Spring Data repo
│           ├── TenantSettingsSpringDataRepository.java # Renamed from TenantSettingsRepository
│           └── TenantAwareRepository.java           # Moved from repository/
├── event/                                          # Module-root — public API for inter-module comms
│   ├── TenantStatusChanged.java                    # Unchanged
│   └── AuditEntryRequested.java                    # Unchanged
└── config/                                         # Module-root — infrastructure stays put
    ├── TenantContext.java
    ├── TenantContextHolder.java
    ├── DataSourceConfig.java
    ├── TenantDataSourceProperties.java
    └── MultiTenantProperties.java

2.2 Identity Module

com.altarys.papillon.platform.identity/
└── adapter/
    └── out/
        └── security/
            ├── SecurityConfig.java
            ├── TenantResolutionFilter.java
            ├── TenantMdcFilter.java
            └── JwtTenantExtractor.java

Mostly adapter/infrastructure today. domain/ + usecase/ packages added when CP-003 (users/roles) lands.

2.3 Shared Config (unchanged)

com.altarys.papillon.platform.config/
├── ApiResponse.java
├── ApiError.java
└── GlobalExceptionHandler.java

3. UseCases for Tenant Module

3.1 TransitionTenantUseCase

Field Value
Interface domain/port/in/TransitionTenantUseCase.java
Implementation domain/usecase/TransitionTenantUseCaseImpl.java (~40 lines)
Injected ports TenantPersistencePort, ApplicationEventPublisher
Workflow Load tenant (platform DB) → validate via TenantStateMachine → save → publish TenantStatusChanged
Why UseCase Coordinates load + state machine validation + persist + event publishing

3.2 UpdateTenantSettingsUseCase

Field Value
Interface domain/port/in/UpdateTenantSettingsUseCase.java
Implementation domain/usecase/UpdateTenantSettingsUseCaseImpl.java (~120 lines)
Injected ports TenantSettingsPersistencePort, ApplicationEventPublisher
Input UpdateSettingsCommand (domain record, not web DTO)
Workflow Load/create-defaults → apply partial update with diff detection → publish AuditEntryRequested per changed field → save
Why UseCase Per-field audit tracking is real business logic

3.3 What Does NOT Get a UseCase

Operation Why not How accessed
getTenantInfo() Pure read, no business logic Controller → TenantPersistencePort.findById() directly
getSettings() Read + create-defaults, thin logic Controller → TenantSettingsPersistencePort directly
getDefaultApprover() Stub endpoint Controller returns hardcoded data

Rule: Promote to UseCase later if complexity grows. Start lean.


4. TenantService.java (345 lines) Split Map

TenantService.java is deleted after being split into 5 files:

Lines (approx) Destination Description
State transition logic TransitionTenantUseCaseImpl.java (~40 lines) Load, validate, save, publish
Settings update + audit UpdateTenantSettingsUseCaseImpl.java (~120 lines) Partial update, field diff, audit events
Platform DB SQL queries TenantPersistenceAdapter.java (~60 lines) JdbcClient wrapping
Settings repo delegation TenantSettingsPersistenceAdapter.java (~40 lines) Spring Data repo wrapping
DTO mapping methods TenantDtoMapper.java (~40 lines) toTenantResponse, toSettingsResponse, toCommand

5. File Migration Map

Current Target Action
tenant/domain/*.java (6 files) tenant/domain/model/*.java MOVE
tenant/api/*.java (4 files) tenant/adapter/in/web/*.java MOVE
tenant/repository/*.java (2 files) tenant/adapter/out/persistence/*.java MOVE + RENAME TenantSettingsRepository
tenant/service/TenantService.java (deleted) SPLIT into 5 new files
tenant/event/*.java (unchanged) NO CHANGE
tenant/config/*.java (unchanged) NO CHANGE
identity/config/*.java (4 files) identity/adapter/out/security/*.java MOVE
config/*.java (3 files) (unchanged) NO CHANGE

New files created: 8 (2 port interfaces, 2 UseCase implementations, 2 persistence adapters, 1 domain command, 1 DTO mapper)


6. Phased Execution

Each phase ends with ./gradlew test — all 89 tests must stay green.

Phase 1: Create port interfaces + domain command (additive only)

New files only — nothing wired yet: - domain/port/out/TenantPersistencePort.java - domain/port/out/TenantSettingsPersistencePort.java - domain/model/UpdateSettingsCommand.java

Phase 2: Create adapters implementing ports (additive only)

New files only — nothing wired yet: - adapter/out/persistence/TenantPersistenceAdapter.java (wraps @Qualifier("platformJdbcClient") JdbcClient) - adapter/out/persistence/TenantSettingsPersistenceAdapter.java (wraps Spring Data repo)

Phase 3: Create UseCases (additive only)

New files only — nothing wired yet: - domain/port/in/TransitionTenantUseCase.java (interface) - domain/port/in/UpdateTenantSettingsUseCase.java (interface) - domain/usecase/TransitionTenantUseCaseImpl.java - domain/usecase/UpdateTenantSettingsUseCaseImpl.java

Phase 4: Create mapper + rewire controller (behavior-preserving change)

The only phase that changes existing behavior wiring: - Create adapter/in/web/TenantDtoMapper.java - Modify TenantController.java: inject UseCase ports + persistence ports instead of TenantService - HTTP contract stays identical

Phase 5: Delete TenantService + move packages (rename only)

Import-path-only changes: - Delete service/TenantService.java - Move domain/*.javadomain/model/*.java - Move api/*.javaadapter/in/web/*.java - Move repository/*.javaadapter/out/persistence/*.java + rename - Move identity/config/*.javaidentity/adapter/out/security/*.java - Update all imports (source + test) - ./gradlew spotlessApply

Phase 6: Update standards + documentation

  • Update docs/standards/java-spring-guidelines.md with hexagonal module structure
  • Update DEVELOPER personality to reference new structure

Why this order: Phases 1–3 are purely additive. Phase 4 is the wiring change (if it breaks, it's isolated). Phase 5 is renames only. This means breakage is always traceable to Phase 4.


7. Impact on Tests

No test logic changes — only import path updates in Phase 5.

Test File Impact
TenantControllerIntegrationTest.java Import paths (apiadapter.in.web)
TenantStateMachineTest.java Import path (domaindomain.model)
JwtTenantExtractorTest.java Import path (configadapter.out.security)
SecurityConfigIntegrationTest.java Import path
TenantResolutionFilterIntegrationTest.java Import path
All others None or minimal import updates

New tests to add (good practice): - TransitionTenantUseCaseTest.java — unit test with mocked ports (fast, no Spring context) - UpdateTenantSettingsUseCaseTest.java — unit test with mocked ports (fast, no Spring context)


8. Architectural Rules

These rules apply to all modules going forward and must be documented in java-spring-guidelines.md:

  1. One UseCase per business workflow, not per CRUD verb
  2. Simple reads skip UseCase — controller → port directly
  3. ApplicationEventPublisher injected directly in UseCases (no port wrapper needed)
  4. No ports for @ConfigurationProperties or mappers
  5. Domain entities MAY keep Spring Data JDBC annotations (@Table, @Id) — no separate persistence entities
  6. Web DTOs (with Jakarta Validation) live in adapter/in/web/, never in domain/
  7. Domain commands (records) live in domain/model/ — UseCase accepts commands, not web DTOs
  8. Dependency direction: domain/ packages NEVER import from adapter/ (arrow points inward)
  9. event/ at module root — it's the module's public API for Spring Modulith inter-module communication
  10. config/ at module root — cross-cutting infrastructure that doesn't fit in/out adapter pattern

DEVELOPER Decision Table

What you're building Where it goes
Entity with @Table domain/model/
Enum, value object domain/model/
Domain exception domain/model/
Domain command record domain/model/
Business workflow (multi-step, events, validation) domain/port/in/ (interface) + domain/usecase/ (impl)
Simple CRUD read Controller calls port directly, no UseCase
JdbcClient SQL or Spring Data JDBC interface adapter/out/persistence/
REST controller adapter/in/web/
Request/response DTO adapter/in/web/
Domain event consumed by other modules event/
Module config, properties config/

UseCase Naming Convention

  • Interface: <Verb><Noun>UseCase (e.g. InviteUserUseCase, SubmitAbsenceRequestUseCase)
  • Implementation: <Verb><Noun>UseCaseImpl

Testing Pattern

  • Unit test UseCases by mocking ports — fast, no Spring context
  • Integration test adapters with Testcontainers (optional, covered by controller tests)
  • Integration test controllers with MockMvc (unchanged from today)

9. Impact on CP-003+ Stories

All future stories follow the hexagonal structure from the start:

  1. No service/ packages — write UseCases instead
  2. Start with ports — define port/in/ and port/out/ interfaces first, then implement
  3. Cross-module access — use Spring Application Events or expose a well-defined query port. Never import another module's persistence adapter directly
  4. Sprint Modulith boundaries remain sacred — hexagonal is intra-module structure, Modulith is inter-module boundaries

10. Verification

./gradlew spotlessApply && ./gradlew test
# Expected: all 89 tests green, zero behavior changes