En esta guía (4 elementos)
Hexagonal Architecture
Architecture
BINDING RULES FOR ALL AI AGENTS: The patterns documented here are MANDATORY. Every new class MUST follow the layer placement rules. Every new adapter MUST implement the correct port interface. Violations of dependency direction (domain depending on infrastructure, use cases depending on adapters) are NEVER acceptable. Cross-cutting concerns (caching, metrics, logging) MUST be implemented as decorator adapters via the Chain of Responsibility pattern.
Decisions: every architecturally significant choice (port shape, adapter technology, transaction strategy, outbox topology, layer boundary change) MUST be captured as an ADR per adr.md before merging. Default template = MADR. Default location =
docs/adr/. Reviewing existing accepted ADRs is a prerequisite before proposing a conflicting design.
This project follows Hexagonal Architecture (Ports and Adapters) with a Gradle multi-module layout. The goal is to isolate the domain and application logic from frameworks and I/O while making adapters replaceable.
Use Cases in the Application Layer (not the Domain)
Definition
A Use Case is an application-level service that orchestrates the Domain Model to fulfill a user or system intent (e.g., CreateInvoice, PayInvoice). It coordinates domain objects and ports, applies application policies (transactions, security, idempotency, retries), and translates between the outside world and the domain—without embedding technical details (DB/HTTP/Kafka) or the business rules themselves.
Why the application layer?
-
Separation of concerns: keeps business rules (Entities, Value Objects, Domain Services, Domain Events) pure in the Domain, while workflow/orchestration lives in Application.
-
Dependency rule: Application depends inward on Domain; Infrastructure depends on Application. This prevents framework/I/O concerns from leaking into Domain.
-
Testability: Use cases are thin, technology-agnostic coordinators—easy to unit test with port fakes/mocks.
-
Policy placement: Transactions, authorization, idempotency, saga/compensation are application policies, not domain rules.
Responsibilities of a Use Case
Validate and normalize scenario inputs (beyond low-level DTO validation).
Load aggregates via outbound ports (e.g., InvoiceRepository), invoke domain behavior, and persist changes.
Publish domain events via an application port (e.g., DomainEventPublisher).
Enforce application policies (transaction boundaries, idempotency keys, security checks).
Expose an inbound port (interface) consumed by entry points (REST, messaging, CLI).
Non-responsibilities (anti-patterns)
❌ Implementing business rules that belong to the Domain (that’s for entities/domain services).
❌ Calling frameworks directly (no JDBC/JPA/Kafka/WebClient here).
❌ Depending on framework annotations or types (keep it POJO/record).
Typical dependency flow:
Entry Point (REST/Kafka) -> Inbound Port (Use Case Interface)Use Case (Application) -> Domain Model (Entities/VOs/Domain Services)Use Case -> Outbound Ports (Repository/EventPublisher)Infrastructure -> Implements Outbound Ports & hosts Entry PointsPort placement note
Repositories can be defined either in Domain (pure, ubiquitous language) or Application (pragmatic hexagonal). In both variants, Use Cases remain in Application.
One-liner for your README
Use cases live in the Application layer: they orchestrate domain behavior, apply application policies, and talk to the outside world through ports—keeping the Domain pure and the Infrastructure replaceable.
Modules in this repository
- domain: pure domain model and enums.
- application: use cases and ports (inbound/outbound). Depends on domain only.
- infrastructure
- entry-points
- rest-controller: exposes HTTP endpoints; maps requests/responses; handlers; filters and others web or rest apis configs
- graphql-controller: exposes GraphQL schema and resolvers; handles queries and mutations.
- webhook-controller: handles incoming webhooks from external systems, providing endpoints for third-party integrations.
- scheduler-task: executes periodic background tasks and jobs using Spring Scheduling or similar.
- kafka-consumer: place for inbound messages if needed (currently stub module).
- driven-adapters
- jdbc-repository: MyBatis-based persistence for invoices.
- kafka-producer: publishes domain events to Kafka.
- entry-points
- bootstrap: Spring Boot application, wiring of beans and configuration.
Dependency rules
- domain: no dependencies on any other module.
- application → domain (only).
- infrastructure (entry/driven) → application and domain as needed.
- bootstrap → application + infrastructure to wire everything.
- Never depend inward on infrastructure from domain/application.
Key packages (examples)
- your.base.package.domain: Invoice, value objects, enums.
- your.base.package.usecase: InvoiceUseCase and inbound/outbound ports.
- your.base.package.infrastructure.entry.points.rest.controller: controllers, requests, responses, mappers.
- your.base.package.infrastructure.entry.points.graphql.controller: resolvers, schema, mappers.
- your.base.package.infrastructure.entry.points.webhook.controller: webhook handlers and DTOs.
- your.base.package.infrastructure.entry.points.scheduler.task: task definitions and scheduling logic.
- your.base.package.infrastructure.driven.adapters.jdbc: MyBatis config, repository, entities, mappers.
- your.base.package.infrastructure.driven.adapters.kafka: publishers, message models.
- your.base.package.config (bootstrap): bean configuration for use cases, adapters.
Data flow (example: Create Invoice)
- REST/GraphQL/Webhook receives request DTO or Scheduler triggers a task.
- Entry point validates and maps DTO or task parameters to domain model/commands.
- Entry point calls inbound port (e.g., CreateInvoicePort).
- Use case applies business rules; calls outbound ports inside a single TX:
InvoiceRepositoryPortto persist the aggregate.OutboxRepositoryPort+OutboxEventFactoryto enqueue the domain event row (NO broker call).
- Outbound adapters map domain to tech-specific structures (e.g.
InvoiceJdbcEntity), handle I/O. The SDK dispatcher publishes outbox rows asynchronously viaMessagePublisherPort. - Use case returns domain model; entry point maps it to response DTO (if applicable).
Transactions
-
DO NOT place @Transactional directly in use cases (application module). Instead, define a Transaction abstraction (port) in the application layer and implement it in infrastructure.
-
This keeps the application layer free from Spring/framework annotations.
-
Use functional interfaces for transaction boundaries:
// Application layerfun interface TransactionPort<R> {fun run(callback: TransactionCallbackPort<R>): R}fun interface TransactionCallbackPort<R> {fun call(): R}// Infrastructure layer@Componentclass JdbcTransaction<R> : TransactionPort<R> {@Transactional(propagation = Propagation.REQUIRED, rollbackFor = [Exception::class])override fun run(callback: TransactionCallbackPort<R>): R {return callback.call()}}// Use case usageclass CreateOrganizationUseCase(private val transaction: TransactionPort<Organization>,// other dependencies) : CreateOrganizationPort {override fun execute(command: CreateOrganizationCommand): Organization {return transaction.run {// All operations here are transactionalval org = repository.save(command.toOrganization())publisher.publishCreated(org)return@run org}}}Java:
// Application layer@FunctionalInterfacepublic interface TransactionPort<R> {R run(TransactionCallbackPort<R> callback);}@FunctionalInterfacepublic interface TransactionCallbackPort<R> {R call();}// Infrastructure layer@Componentpublic class JdbcTransaction<R> implements TransactionPort<R> {@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)@Overridepublic R run(TransactionCallbackPort<R> callback) {return callback.call();}}// Use case usagepublic class CreateOrganizationUseCase implements CreateOrganizationPort {private final TransactionPort<Organization> transaction;// other dependenciespublic CreateOrganizationUseCase(TransactionPort<Organization> transaction /* other deps */) {this.transaction = transaction;}@Overridepublic Organization execute(CreateOrganizationCommand command) {return transaction.run(() -> {// All operations here are transactionalOrganization org = repository.save(command.toOrganization());publisher.publishCreated(org);return org;});}} -
Rollback configuration: Use
rollbackFor = [Exception::class]to ensure all exceptions trigger rollback, not just checked exceptions.
Mapping guidelines
- Keep dedicated mappers per boundary:
- REST/GraphQL mappers: domain ↔ request/response.
- JDBC mapper: domain ↔ entity and MyBatis mapping.
- Kafka mapper: domain ↔ Kafka payload.
- No business logic in mappers.
Persistence (Hibernate, MyBatis)
- Entities reflect table columns; use explicit column mapping.
- Keep SQL in mapper XML if queries become complex; avoid
SELECT *. - Liquibase manages schema: add a new changeSet per change using timestamped filenames.
Messaging & Transactional Outbox Pattern
Source of truth: messaging.md. This section is a thin pointer; do not duplicate.
Hard rules (enforced by ArchUnit — see §ArchUnit below):
- Use cases NEVER call a broker SDK (
KafkaTemplate,RabbitTemplate,SqsClient, etc.) and NEVER injectMessagePublisherPortor any domain-specific publisher port (XxxPublisherPort). They inject onlyOutboxRepositoryPort+OutboxEventFactory(andTransactionfor the boundary). - Use cases publish via outbox-only. The business write and the outbox insert happen in one local transaction. There is no “fast path”, no “publish first, outbox on failure” fallback — both are dual-write back doors.
- A separate dispatcher (
SendOutboxMessagesUseCasefromcodehuntersio-sdk-event-messaging) reads the outbox, publishes viaMessagePublisherPort, and retries with exponential backoff under a per-service@SchedulerLock. See messaging.md §5 Claim-Based Dispatch. - Topology stays outside microservice code — no
@Bean Queue / Exchange / Binding / NewTopic. See messaging.md §9. - Consumers MUST be idempotent. See messaging.md §7.
Canonical use-case shape (Kotlin):
class CreateOrganizationUseCase( private val transaction: TransactionPort, private val organizations: OrganizationRepositoryPort, private val outbox: OutboxRepositoryPort, private val events: OutboxEventFactory,) : CreateOrganizationPort {
override fun execute(command: CreateOrganizationCommand): Organization = transaction.run { val org = organizations.save(command.toOrganization()) outbox.save(events.create( topic = "platform.organizations", traceId = command.traceId, aggregate = org.id.toString(), eventName = "OrganizationCreated", eventType = EventTypes.EVENT, payload = OrganizationCreatedPayload.from(org), )) org }}No publisher port. No kafkaTemplate.send. No callback. The SDK dispatcher does the rest.
Recipe for adopting in a new service: add the codehuntersio-sdk-event-messaging starter, run the SDK’s outbox_message + shedlock migrations, set spring.application.name, inject OutboxRepositoryPort + OutboxEventFactory in use cases. To swap broker, override MessagePublisherPort with your KafkaMessagePublisherAdapter — every @ConditionalOnMissingBean in the SDK auto-config defers.
For the schema, claim SQL, ShedLock annotation, retry math, idempotency, schema evolution, tracing, metrics and pre-merge checklist, read messaging.md — do NOT reinvent these here.
Error handling strategy
- Domain/application: use domain-specific exceptions or a Result type.
- Entry points: convert exceptions to HTTP status/GraphQL errors (see GlobalExceptionHandler).
- Infrastructure: wrap low-level exceptions and propagate meaningful errors upward.
Chain of Responsibility Pattern (Decorator Pattern)
- Use Chain of Responsibility to layer cross-cutting concerns (caching, metrics, logging) in front of core adapters.
- This pattern allows adding functionality without modifying use cases or core repository implementations.
Implementation:
-
Define ChainablePort Interface (in infrastructure or application support):
interface ChainablePort<T> : Ordered {fun setDelegate(delegate: T)}Java:
public interface ChainablePort<T> extends Ordered {void setDelegate(T delegate);} -
Implement Decorators with @Order:
// Cache layer - highest priority (checked first)@Component@Order(50)class OrganizationCacheAdapter(private val redisTemplate: RedisTemplate<String, Any>) : OrganizationRepositoryPort, ChainablePort<OrganizationRepositoryPort> {private lateinit var delegate: OrganizationRepositoryPortoverride fun setDelegate(delegate: OrganizationRepositoryPort) {this.delegate = delegate}override fun findById(id: UUID): Organization? {// Check cache firstval cached = redisTemplate.opsForValue().get("org:$id")if (cached != null) {log.debug("Cache hit for organization: {}", id)return cached as Organization}// Cache miss - delegate to next in chain (typically DB adapter)val result = delegate.findById(id)if (result != null) {redisTemplate.opsForValue().set("org:$id", result, Duration.ofMinutes(10))}return result}override fun save(organization: Organization): Organization {val result = delegate.save(organization)// Invalidate or update cacheredisTemplate.delete("org:${result.id}")return result}}// Metrics layer - medium priority@Component@Order(75)class OrganizationMetricsAdapter(private val meterRegistry: MeterRegistry) : OrganizationRepositoryPort, ChainablePort<OrganizationRepositoryPort> {private lateinit var delegate: OrganizationRepositoryPortoverride fun setDelegate(delegate: OrganizationRepositoryPort) {this.delegate = delegate}override fun findById(id: UUID): Organization? {val timer = Timer.start(meterRegistry)return try {val result = delegate.findById(id)meterRegistry.counter("repository.findById.success",Tags.of("entity", "organization")).increment()result} catch (ex: Exception) {meterRegistry.counter("repository.findById.error",Tags.of("entity", "organization")).increment()throw ex} finally {timer.stop(meterRegistry.timer("repository.findById.duration",Tags.of("entity", "organization")))}}}// Database adapter - lowest priority (fallback/actual implementation)@Component@Order(100)class OrganizationRepositoryAdapter(private val jdbcRepository: OrganizationJdbcRepository,private val mapper: EntityJdbcMapper) : OrganizationRepositoryPort {override fun findById(id: UUID): Organization? {return jdbcRepository.findById(id).map { mapper.toDomain(it) }.orElse(null)}override fun save(organization: Organization): Organization {val entity = mapper.toEntityForInsert(organization)return mapper.toDomain(jdbcRepository.save(entity))}}Java:
// Cache layer - highest priority (checked first)@Component@Order(50)public class OrganizationCacheAdapter implements OrganizationRepositoryPort, ChainablePort<OrganizationRepositoryPort> {private final RedisTemplate<String, Object> redisTemplate;private OrganizationRepositoryPort delegate;public OrganizationCacheAdapter(RedisTemplate<String, Object> redisTemplate) {this.redisTemplate = redisTemplate;}@Overridepublic void setDelegate(OrganizationRepositoryPort delegate) {this.delegate = delegate;}@Overridepublic Organization findById(UUID id) {// Check cache firstObject cached = redisTemplate.opsForValue().get("org:" + id);if (cached != null) {log.debug("Cache hit for organization: {}", id);return (Organization) cached;}// Cache miss - delegate to next in chain (typically DB adapter)Organization result = delegate.findById(id);if (result != null) {redisTemplate.opsForValue().set("org:" + id, result, Duration.ofMinutes(10));}return result;}@Overridepublic Organization save(Organization organization) {Organization result = delegate.save(organization);// Invalidate or update cacheredisTemplate.delete("org:" + result.getId());return result;}@Overridepublic int getOrder() { return 50; }}// Metrics layer - medium priority@Component@Order(75)public class OrganizationMetricsAdapter implements OrganizationRepositoryPort, ChainablePort<OrganizationRepositoryPort> {private final MeterRegistry meterRegistry;private OrganizationRepositoryPort delegate;public OrganizationMetricsAdapter(MeterRegistry meterRegistry) {this.meterRegistry = meterRegistry;}@Overridepublic void setDelegate(OrganizationRepositoryPort delegate) {this.delegate = delegate;}@Overridepublic Organization findById(UUID id) {Timer.Sample timer = Timer.start(meterRegistry);try {Organization result = delegate.findById(id);meterRegistry.counter("repository.findById.success",Tags.of("entity", "organization")).increment();return result;} catch (Exception ex) {meterRegistry.counter("repository.findById.error",Tags.of("entity", "organization")).increment();throw ex;} finally {timer.stop(meterRegistry.timer("repository.findById.duration",Tags.of("entity", "organization")));}}@Overridepublic int getOrder() { return 75; }}// Database adapter - lowest priority (fallback/actual implementation)@Component@Order(100)public class OrganizationRepositoryAdapter implements OrganizationRepositoryPort {private final OrganizationJdbcRepository jdbcRepository;private final EntityJdbcMapper mapper;public OrganizationRepositoryAdapter(OrganizationJdbcRepository jdbcRepository, EntityJdbcMapper mapper) {this.jdbcRepository = jdbcRepository;this.mapper = mapper;}@Overridepublic Organization findById(UUID id) {return jdbcRepository.findById(id).map(mapper::toDomain).orElse(null);}@Overridepublic Organization save(Organization organization) {var entity = mapper.toEntityForInsert(organization);return mapper.toDomain(jdbcRepository.save(entity));}} -
Wire Chain in Configuration (bootstrap module):
@Configurationclass UseCaseConfig(private val factory: BeanFactory) {@Bean@Primaryfun organizationRepository(): OrganizationRepositoryPort {return buildChain(OrganizationRepositoryPort::class.java)}private fun <T : Any> buildChain(type: Class<T>): T {// Get all beans of this typeval beans = factory.getBeansOfType(type).values.toList()// Sort by order (lowest number = highest priority)val ordered = beans.sortedWith(OrderComparator.INSTANCE)// Wire delegates: each adapter delegates to the nextfor (i in 0 until ordered.size - 1) {val current = ordered[i]val next = ordered[i + 1]if (current is ChainablePort<*>) {@Suppress("UNCHECKED_CAST")(current as ChainablePort<T>).setDelegate(next)}}// Return the first (highest priority) adapterreturn ordered.first()}}
Key Benefits:
- Separation of Concerns: Cross-cutting concerns isolated from business logic
- Open/Closed Principle: Add new decorators without modifying existing code
- Composability: Mix and match decorators as needed
- Testability: Test each layer independently; mock decorators in use case tests
- Runtime Configuration: Enable/disable decorators via Spring profiles or feature flags
Usage in Use Cases:
class CreateOrganizationUseCase( private val organizationRepository: OrganizationRepositoryPort // Receives the chain head) : CreateOrganizationPort { override fun execute(command: CreateOrganizationCommand): Organization { // Use case doesn't know about caching or metrics - just calls the repository return organizationRepository.save(command.toOrganization()) }}Order Guidelines:
- 1-49: Pre-processing decorators (validation, transformation)
- 50-74: Caching layers
- 75-89: Metrics and observability
- 90-99: Logging and auditing
- 100+: Actual implementations (database, external APIs)
Testing strategy
- Unit tests in domain and application modules.
- Adapters: slice/integration tests (e.g., using Testcontainers with a real DB/Kafka when needed).
- Contract tests for REST/GraphQL schemas to prevent breaking changes.
- Architecture tests with ArchUnit: Enforce hexagonal architecture boundaries at build time.
Architecture Testing with ArchUnit:
Create tests to enforce architectural rules:
@AnalyzeClasses(packages = ["com.example.platform"])class HexagonalArchitectureTest {
@ArchTest val domainShouldNotDependOnInfrastructure = noClasses() .that().resideInAPackage("..domain..") .should().dependOnClassesThat().resideInAPackage("..infrastructure..") .because("Domain must be free from infrastructure dependencies")
@ArchTest val domainShouldNotDependOnApplication = noClasses() .that().resideInAPackage("..domain..") .should().dependOnClassesThat().resideInAPackage("..usecase..") .because("Domain must not depend on application layer")
@ArchTest val applicationUseCasesShouldNotDependOnInfrastructure = noClasses() .that().resideInAPackage("..usecase..") .and().haveSimpleNameEndingWith("UseCase") .should().dependOnClassesThat().resideInAPackage("..infrastructure..") .because("Use cases must not depend on infrastructure implementations")
@ArchTest val entryPointsShouldNotDependOnDrivenAdapters = noClasses() .that().resideInAPackage("..infrastructure.entry.points..") .should().dependOnClassesThat().resideInAPackage("..infrastructure.driven.adapters..") .because("Entry points should only depend on ports, not on driven adapter implementations")
@ArchTest val drivenAdaptersShouldNotAccessUseCases = noClasses() .that().resideInAPackage("..infrastructure.driven.adapters..") .should().accessClassesThat().resideInAPackage("..usecase..") .because("Driven adapters should not directly access use case implementations")
@ArchTest val portsShouldBeInterfaces = classes() .that().resideInAPackage("..port..") .should().beInterfaces() .because("Ports should be contracts (interfaces)")
@ArchTest val useCasesShouldNotHaveSpringAnnotations = noClasses() .that().resideInAPackage("..usecase..") .should().beAnnotatedWith("org.springframework.stereotype.Service") .orShould().beAnnotatedWith("org.springframework.transaction.annotation.Transactional") .because("Use cases should be framework-agnostic")
@ArchTest val domainShouldNotHaveSpringAnnotations = noClasses() .that().resideInAPackage("..domain..") .should().dependOnClassesThat().resideInAPackage("org.springframework..") .because("Domain must be pure and framework-free")
// Outbox-first: use cases NEVER touch broker SDKs or the publisher port. @ArchTest val useCasesShouldNotUseBrokerSdks = noClasses() .that().resideInAPackage("..usecase..") .should().dependOnClassesThat().resideInAnyPackage( "org.springframework.kafka..", "org.apache.kafka..", "org.springframework.amqp..", "com.rabbitmq..", "software.amazon.awssdk.services.sqs..", "software.amazon.awssdk.services.sns..", ) .because("Use cases must publish via the outbox; broker SDKs belong in the dispatcher adapter")
@ArchTest val useCasesShouldNotInjectMessagePublisherPort = noClasses() .that().resideInAPackage("..usecase..") .should().dependOnClassesThat().haveSimpleName("MessagePublisherPort") .because("MessagePublisherPort is the dispatcher's port; use cases inject OutboxRepositoryPort + OutboxEventFactory")
@ArchTest val useCasesShouldNotInjectDomainPublisherPorts = noClasses() .that().resideInAPackage("..usecase..") .should().dependOnClassesThat().haveSimpleNameEndingWith("PublisherPort") .because("No domain-specific publisher ports (XxxPublisherPort) — they invite dual-write; use OutboxRepositoryPort instead")
@ArchTest val brokerTemplatesShouldOnlyLiveInPublisherAdapters = noClasses() .that().dependOnClassesThat().haveSimpleNameEndingWith("KafkaTemplate") .or().dependOnClassesThat().haveSimpleNameEndingWith("RabbitTemplate") .should().resideOutsideOfPackages( "..infrastructure.driven.adapters.kafka..", "..infrastructure.driven.adapters.rabbitmq..", "..infrastructure.entry.points.scheduler..", // SDK OutboxScheduler exception ) .because("Broker templates may only be wired into MessagePublisherPort adapters")}Java:
@AnalyzeClasses(packages = "com.example.platform")class HexagonalArchitectureTest {
@ArchTest static final ArchRule domainShouldNotDependOnInfrastructure = noClasses() .that().resideInAPackage("..domain..") .should().dependOnClassesThat().resideInAPackage("..infrastructure..") .because("Domain must be free from infrastructure dependencies");
@ArchTest static final ArchRule domainShouldNotDependOnApplication = noClasses() .that().resideInAPackage("..domain..") .should().dependOnClassesThat().resideInAPackage("..usecase..") .because("Domain must not depend on application layer");
@ArchTest static final ArchRule applicationUseCasesShouldNotDependOnInfrastructure = noClasses() .that().resideInAPackage("..usecase..") .and().haveSimpleNameEndingWith("UseCase") .should().dependOnClassesThat().resideInAPackage("..infrastructure..") .because("Use cases must not depend on infrastructure implementations");
@ArchTest static final ArchRule entryPointsShouldNotDependOnDrivenAdapters = noClasses() .that().resideInAPackage("..infrastructure.entry.points..") .should().dependOnClassesThat().resideInAPackage("..infrastructure.driven.adapters..") .because("Entry points should only depend on ports, not on driven adapter implementations");
@ArchTest static final ArchRule drivenAdaptersShouldNotAccessUseCases = noClasses() .that().resideInAPackage("..infrastructure.driven.adapters..") .should().accessClassesThat().resideInAPackage("..usecase..") .because("Driven adapters should not directly access use case implementations");
@ArchTest static final ArchRule portsShouldBeInterfaces = classes() .that().resideInAPackage("..port..") .should().beInterfaces() .because("Ports should be contracts (interfaces)");
@ArchTest static final ArchRule useCasesShouldNotHaveSpringAnnotations = noClasses() .that().resideInAPackage("..usecase..") .should().beAnnotatedWith("org.springframework.stereotype.Service") .orShould().beAnnotatedWith("org.springframework.transaction.annotation.Transactional") .because("Use cases should be framework-agnostic");
@ArchTest static final ArchRule domainShouldNotHaveSpringAnnotations = noClasses() .that().resideInAPackage("..domain..") .should().dependOnClassesThat().resideInAPackage("org.springframework..") .because("Domain must be pure and framework-free");
// Outbox-first: use cases NEVER touch broker SDKs or the publisher port. @ArchTest static final ArchRule useCasesShouldNotUseBrokerSdks = noClasses() .that().resideInAPackage("..usecase..") .should().dependOnClassesThat().resideInAnyPackage( "org.springframework.kafka..", "org.apache.kafka..", "org.springframework.amqp..", "com.rabbitmq..", "software.amazon.awssdk.services.sqs..", "software.amazon.awssdk.services.sns..") .because("Use cases must publish via the outbox; broker SDKs belong in the dispatcher adapter");
@ArchTest static final ArchRule useCasesShouldNotInjectMessagePublisherPort = noClasses() .that().resideInAPackage("..usecase..") .should().dependOnClassesThat().haveSimpleName("MessagePublisherPort") .because("MessagePublisherPort is the dispatcher's port; use cases inject OutboxRepositoryPort + OutboxEventFactory");
@ArchTest static final ArchRule useCasesShouldNotInjectDomainPublisherPorts = noClasses() .that().resideInAPackage("..usecase..") .should().dependOnClassesThat().haveSimpleNameEndingWith("PublisherPort") .because("No domain-specific publisher ports (XxxPublisherPort) — they invite dual-write; use OutboxRepositoryPort instead");
@ArchTest static final ArchRule brokerTemplatesShouldOnlyLiveInPublisherAdapters = noClasses() .that().dependOnClassesThat().haveSimpleNameEndingWith("KafkaTemplate") .or().dependOnClassesThat().haveSimpleNameEndingWith("RabbitTemplate") .should().resideOutsideOfPackages( "..infrastructure.driven.adapters.kafka..", "..infrastructure.driven.adapters.rabbitmq..", "..infrastructure.entry.points.scheduler..") .because("Broker templates may only be wired into MessagePublisherPort adapters");}Run these tests as part of your build:
// In build.gradle.kts (bootstrap module)dependencies { testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0")}
tasks.test { useJUnitPlatform()}Benefits:
- Fail Fast: Architecture violations caught at compile/test time, not code review
- Documentation: Tests serve as executable architecture documentation
- Regression Prevention: Prevents accidental dependency direction reversals
- Onboarding: New developers understand boundaries from reading tests
Observability & Metrics Adapters:
Use decorators to add metrics without polluting business logic:
@Component@Order(75)class OrganizationMetricsAdapter( private val meterRegistry: MeterRegistry) : OrganizationRepositoryPort, ChainablePort<OrganizationRepositoryPort> {
private lateinit var delegate: OrganizationRepositoryPort
override fun setDelegate(delegate: OrganizationRepositoryPort) { this.delegate = delegate }
private fun <T> record(operation: String, block: () -> T): T { val tags = Tags.of("entity", "organization", "operation", operation) val timer = Timer.start(meterRegistry)
return try { val result = block() meterRegistry.counter("repository.calls", tags.and("success", "true")).increment() result } catch (ex: Exception) { meterRegistry.counter("repository.calls", tags.and("success", "false")).increment() meterRegistry.counter("repository.errors", tags.and("error", ex.javaClass.simpleName)).increment() throw ex } finally { timer.stop(meterRegistry.timer("repository.duration", tags)) } }
override fun findById(id: UUID): Organization? { return record("findById") { delegate.findById(id) } }
override fun save(organization: Organization): Organization { return record("save") { delegate.save(organization) } }
override fun update(organization: Organization): Organization { return record("update") { delegate.update(organization) } }
override fun getOrder(): Int = 75}Java:
@Component@Order(75)public class OrganizationMetricsAdapter implements OrganizationRepositoryPort, ChainablePort<OrganizationRepositoryPort> {
private final MeterRegistry meterRegistry; private OrganizationRepositoryPort delegate;
public OrganizationMetricsAdapter(MeterRegistry meterRegistry) { this.meterRegistry = meterRegistry; }
@Override public void setDelegate(OrganizationRepositoryPort delegate) { this.delegate = delegate; }
private <T> T record(String operation, Supplier<T> block) { Tags tags = Tags.of("entity", "organization", "operation", operation); Timer.Sample timer = Timer.start(meterRegistry);
try { T result = block.get(); meterRegistry.counter("repository.calls", tags.and("success", "true")).increment(); return result; } catch (Exception ex) { meterRegistry.counter("repository.calls", tags.and("success", "false")).increment(); meterRegistry.counter("repository.errors", tags.and("error", ex.getClass().getSimpleName())).increment(); throw ex; } finally { timer.stop(meterRegistry.timer("repository.duration", tags)); } }
@Override public Organization findById(UUID id) { return record("findById", () -> delegate.findById(id)); }
@Override public Organization save(Organization organization) { return record("save", () -> delegate.save(organization)); }
@Override public Organization update(Organization organization) { return record("update", () -> delegate.update(organization)); }
@Override public int getOrder() { return 75; }}Available Metrics:
repository.calls{entity,operation,success}- Counter for all repository operationsrepository.duration{entity,operation}- Timer for operation durationrepository.errors{entity,operation,error}- Counter for errors by type
Export to Prometheus/Grafana via Spring Boot Actuator:
management: endpoints: web: exposure: include: health,info,metrics,prometheus metrics: export: prometheus: enabled: trueVersioning & changes
- Backward compatibility for APIs and messages. Use additive DB migrations.
Checklist before adding new features
- Define/extend inbound and outbound ports as needed in application.
- Implement adapters in the correct infrastructure module.
- Add mappers and DTOs; ensure no domain leakage.
- Add Liquibase changeSets and MyBatis mappings where applicable.
- Write tests at the appropriate layers.
- Wire beans in bootstrap config if auto-detection isn’t sufficient.