- Introduction
- Architecture Overview
- Core Principles
- Layer Details
- Dependency Flow
- Adding New Providers
- Testing Strategy
- Design Patterns
The fireflyframework-notifications library implements Hexagonal Architecture (also known as Ports and Adapters pattern) to provide a clean, maintainable, and testable notification system. This architectural style was introduced by Alistair Cockburn and focuses on separating the core business logic from external dependencies.
Traditional layered architectures often suffer from tight coupling between business logic and infrastructure. Hexagonal architecture solves this by:
- Isolating the domain: Business rules don't depend on frameworks or external services
- Enabling testability: Core logic can be tested without real infrastructure
- Facilitating change: Swap providers (e.g., SendGrid → Resend) without touching business logic
- Enforcing boundaries: Clear contracts between layers through interfaces (ports)
┌───────────────────────────────────────────────────────────────────────┐
│ CLIENT APPLICATIONS │
│ (Spring Boot services using this library) │
└───────────────────────────────┬───────────────────────────────────────┘
│
│ uses
▼
┌───────────────────────────────────────────────────────────────────────┐
│ APPLICATION LAYER │
│ (fireflyframework-notifications-core) │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ EmailService │ SMSService │ PushService │ │
│ │ (Service Implementations - Application Logic) │ │
│ └───────────────────┬─────────────────────────────────────┘ │
│ │ depends on (via DI) │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ DOMAIN LAYER (PORTS) │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │EmailProvider │ │ SMSProvider │ │ PushProvider │ │ │
│ │ │ (interface) │ │ (interface) │ │ (interface) │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────┐ │ │
│ │ │ DTOs: EmailRequestDTO, SMSRequestDTO, etc. │ │ │
│ │ └──────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
└───────────────────────────────┬───────────────────────────────────────┘
│
│ implemented by
▼
┌───────────────────────────────────────────────────────────────────────┐
│ INFRASTRUCTURE LAYER (ADAPTERS) │
│ (Separate Maven Modules) │
│ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ fireflyframework-notifications- │ │ fireflyframework-notifications- │ │
│ │ sendgrid │ │ resend │ │
│ │ │ │ │ │
│ │ SendGridEmailProvider│ │ ResendEmailProvider │ │
│ │ implements │ │ implements │ │
│ │ EmailProvider │ │ EmailProvider │ │
│ └──────────────────────┘ └──────────────────────┘ │
│ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ fireflyframework-notifications- │ │ fireflyframework-notifications- │ │
│ │ twilio │ │ firebase │ │
│ │ │ │ │ │
│ │ TwilioSMSProvider │ │ FcmPushProvider │ │
│ │ implements │ │ implements │ │
│ │ SMSProvider │ │ PushProvider │ │
│ └──────────────────────┘ └──────────────────────┘ │
└───────────────────────────────┬───────────────────────────────────────┘
│
│ connects to
▼
┌──────────────────────┐
│ EXTERNAL SERVICES │
│ (SendGrid, Twilio, │
│ Resend, Firebase) │
└──────────────────────┘
High-level modules (application services) don't depend on low-level modules (adapters). Both depend on abstractions (ports/interfaces).
// ✅ CORRECT: Service depends on interface (port)
@Service
public class EmailServiceImpl implements EmailService {
@Autowired
private EmailProvider emailProvider; // Interface, not concrete class
@Override
public Mono<EmailResponseDTO> sendEmail(EmailRequestDTO request) {
return emailProvider.sendEmail(request);
}
}
// ❌ WRONG: Service depending on concrete adapter
@Service
public class EmailServiceImpl implements EmailService {
@Autowired
private SendGridEmailProvider sendGridProvider; // Concrete class - bad!
}Each notification type has its own port interface with a single responsibility:
EmailProvider- Email delivery contractSMSProvider- SMS delivery contractPushProvider- Push notification contract
Each adapter is a separate Maven module that:
- Implements a port interface
- Contains provider-specific configuration
- Has no knowledge of other adapters
- Can be added/removed independently
All communication between layers uses immutable DTOs:
- Request DTOs:
EmailRequestDTO,SMSRequestDTO,PushNotificationRequest - Response DTOs:
EmailResponseDTO,SMSResponseDTO,PushNotificationResponse
Location: fireflyframework-notifications-core/src/main/java/.../interfaces
Responsibilities:
- Define contracts for notification delivery (port interfaces)
- Define data structures (DTOs)
- No business logic, no infrastructure code
Key Interfaces:
public interface EmailProvider {
Mono<EmailResponseDTO> sendEmail(EmailRequestDTO request);
}
public interface SMSProvider {
SMSResponseDTO sendSMS(SMSRequestDTO request);
}
public interface PushProvider {
Mono<PushNotificationResponse> sendPush(PushNotificationRequest request);
}Characteristics:
- Pure interfaces with no implementation
- Technology-agnostic
- Stable contracts that rarely change
- No Spring annotations (except for service discovery in impl)
Location: fireflyframework-notifications-core/src/main/java/.../core/services
Responsibilities:
- Orchestrate notification delivery
- Implement business rules and validation
- Error handling and logging
- Depend only on port interfaces
Implementation Pattern:
@Service
public class EmailServiceImpl implements EmailService {
@Autowired
private EmailProvider emailProvider; // Injected by Spring
@Override
public Mono<EmailResponseDTO> sendEmail(EmailRequestDTO request) {
// Business logic here (validation, logging, etc.)
return emailProvider.sendEmail(request);
}
}Key Points:
- Services are Spring-managed beans (
@Service) - Inject port interfaces via constructor or field injection
- Spring automatically wires the concrete adapter implementation
- Services never know which adapter is being used
Location: Separate Maven modules (fireflyframework-notifications-*)
Responsibilities:
- Implement port interfaces
- Handle provider-specific API calls
- Manage authentication and configuration
- Transform DTOs to provider-specific formats
Adapter Structure (using SendGrid as example):
fireflyframework-notifications-sendgrid/
├── pom.xml # Dependencies (SendGrid SDK, core)
├── README.md # Adapter-specific docs
└── src/main/java/.../providers/sendgrid/
├── core/v1/
│ └── SendGridEmailProvider.java # Port implementation
├── config/v1/
│ └── SendGridConfig.java # Spring configuration
└── properties/v1/
└── SendGridProperties.java # Configuration properties
Adapter Implementation Example:
@Component // Spring bean
public class SendGridEmailProvider implements EmailProvider {
@Autowired
private SendGridProperties properties;
@Autowired
private SendGrid sendGrid;
@Override
public Mono<EmailResponseDTO> sendEmail(EmailRequestDTO request) {
// Provider-specific implementation
return Mono.fromCallable(() -> {
Mail mail = buildMail(request);
Response response = sendGrid.api(sendGridRequest);
return EmailResponseDTO.success(extractMessageId(response));
}).subscribeOn(Schedulers.boundedElastic());
}
private Mail buildMail(EmailRequestDTO request) {
// Transform DTO to SendGrid-specific format
}
}Client Application
↓ (depends on)
fireflyframework-notifications-core (Application + Domain)
↑ (implemented by)
fireflyframework-notifications-sendgrid (Adapter)
fireflyframework-notifications-resend (Adapter)
fireflyframework-notifications-twilio (Adapter)
fireflyframework-notifications-firebase (Adapter)
1. Spring scans for @Component/@Service beans
2. Finds EmailServiceImpl (needs EmailProvider)
3. Finds SendGridEmailProvider (implements EmailProvider)
4. Injects SendGridEmailProvider into EmailServiceImpl
5. Client code calls EmailService methods
6. Calls are routed to SendGridEmailProvider at runtime
The core never imports adapters. Adapters import the core and implement its interfaces. This is the dependency inversion that makes the architecture "hexagonal."
mvn archetype:generate \
-DgroupId=org.fireflyframework \
-DartifactId=fireflyframework-notifications-aws-ses \
-DarchetypeArtifactId=maven-archetype-quickstart<dependency>
<groupId>org.fireflyframework</groupId>
<artifactId>fireflyframework-notifications-core</artifactId>
<version>${project.version}</version>
</dependency>package org.fireflyframework.notifications.providers.awsses.core.v1;
@Component
public class AwsSesEmailProvider implements EmailProvider {
@Override
public Mono<EmailResponseDTO> sendEmail(EmailRequestDTO request) {
// AWS SES implementation
}
}@Configuration
@ConditionalOnProperty(prefix = "aws.ses", name = "region")
public class AwsSesConfig {
@Bean
public SesClient sesClient(AwsSesProperties properties) {
return SesClient.builder()
.region(Region.of(properties.getRegion()))
.build();
}
}Create README.md explaining configuration and usage.
That's it! No changes needed in core or other adapters.
@ExtendWith(MockitoExtension.class)
class EmailServiceImplTest {
@Mock
private EmailProvider mockProvider;
@InjectMocks
private EmailServiceImpl emailService;
@Test
void shouldSendEmailSuccessfully() {
// Given
EmailRequestDTO request = EmailRequestDTO.builder()
.to(List.of("test@example.com"))
.subject("Test")
.text("Hello")
.build();
when(mockProvider.sendEmail(any()))
.thenReturn(Mono.just(EmailResponseDTO.success("msg-123")));
// When
Mono<EmailResponseDTO> result = emailService.sendEmail(request);
// Then
StepVerifier.create(result)
.expectNextMatches(response ->
response.isSuccess() &&
"msg-123".equals(response.getMessageId()))
.verifyComplete();
}
}@SpringBootTest
@TestPropertySource(properties = {
"sendgrid.api-key=test-key"
})
class SendGridEmailProviderIntegrationTest {
@Autowired
private EmailProvider emailProvider;
@Test
void shouldSendRealEmail() {
// Test with real SendGrid API (or mock server)
}
}@SpringBootTest
@TestPropertySource(properties = {
"notifications.email.provider=sendgrid",
"sendgrid.api-key=${SENDGRID_API_KEY}"
})
class SendGridProviderTest {
@Autowired EmailService emailService;
// Tests using SendGrid
}
@SpringBootTest
@TestPropertySource(properties = {
"notifications.email.provider=resend",
"resend.api-key=${RESEND_API_KEY}"
})
class ResendProviderTest {
@Autowired EmailService emailService;
// Same tests, different provider
}Spring's DI container wires dependencies at runtime:
// Core defines what it needs
@Service
public class EmailServiceImpl {
private final EmailProvider provider;
@Autowired // Spring injects the implementation
public EmailServiceImpl(EmailProvider provider) {
this.provider = provider;
}
}Port interfaces represent strategies for notification delivery. The application service is the context that uses these strategies without knowing their concrete implementations.
Each infrastructure module is literally an adapter that:
- Adapts the port interface to a specific provider's API
- Translates between DTOs and provider-specific formats
Spring acts as a factory that creates and manages adapter instances:
@Configuration
public class SendGridConfig {
@Bean
public SendGrid sendGrid(SendGridProperties properties) {
return new SendGrid(properties.getApiKey());
}
}DTOs use builders for immutable object construction:
EmailRequestDTO request = EmailRequestDTO.builder()
.from("sender@example.com")
.to(List.of("recipient@example.com"))
.subject("Hello")
.text("World")
.build();| Benefit | Description |
|---|---|
| Testability | Mock port interfaces to test services without real providers |
| Flexibility | Swap providers by changing Maven dependencies and configuration |
| Maintainability | Clear boundaries make code easier to understand and modify |
| Scalability | Add new providers without modifying existing code (Open/Closed Principle) |
| Independence | Core business logic has zero knowledge of infrastructure details |
| Reusability | Core and adapters can be reused across multiple projects |