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 toadapter/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/*.java → domain/model/*.java
- Move api/*.java → adapter/in/web/*.java
- Move repository/*.java → adapter/out/persistence/*.java + rename
- Move identity/config/*.java → identity/adapter/out/security/*.java
- Update all imports (source + test)
- ./gradlew spotlessApply
Phase 6: Update standards + documentation¶
- Update
docs/standards/java-spring-guidelines.mdwith 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 (api → adapter.in.web) |
TenantStateMachineTest.java |
Import path (domain → domain.model) |
JwtTenantExtractorTest.java |
Import path (config → adapter.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:
- One UseCase per business workflow, not per CRUD verb
- Simple reads skip UseCase — controller → port directly
ApplicationEventPublisherinjected directly in UseCases (no port wrapper needed)- No ports for
@ConfigurationPropertiesor mappers - Domain entities MAY keep Spring Data JDBC annotations (
@Table,@Id) — no separate persistence entities - Web DTOs (with Jakarta Validation) live in
adapter/in/web/, never indomain/ - Domain commands (records) live in
domain/model/— UseCase accepts commands, not web DTOs - Dependency direction:
domain/packages NEVER import fromadapter/(arrow points inward) event/at module root — it's the module's public API for Spring Modulith inter-module communicationconfig/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:
- No
service/packages — write UseCases instead - Start with ports — define
port/in/andport/out/interfaces first, then implement - Cross-module access — use Spring Application Events or expose a well-defined query port. Never import another module's persistence adapter directly
- Sprint Modulith boundaries remain sacred — hexagonal is intra-module structure, Modulith is inter-module boundaries